You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/01/13 19:08:12 UTC
(superset) branch chore/ts-migration-non-dashboard created (now c1b3085e28)
This is an automated email from the ASF dual-hosted git repository.
rusackas pushed a change to branch chore/ts-migration-non-dashboard
in repository https://gitbox.apache.org/repos/asf/superset.git
at c1b3085e28 chore(frontend): migrate non-dashboard JS/JSX files to TypeScript
This branch includes the following new commits:
new c1b3085e28 chore(frontend): migrate non-dashboard JS/JSX files to TypeScript
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(superset) 01/01: chore(frontend): migrate non-dashboard JS/JSX files to TypeScript
Posted by ru...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
rusackas pushed a commit to branch chore/ts-migration-non-dashboard
in repository https://gitbox.apache.org/repos/asf/superset.git
commit c1b3085e28d1fe3b2f12b505e4612d474fbd04be
Author: Evan Rusackas <ev...@rusackas.com>
AuthorDate: Tue Jan 13 10:56:07 2026 -0800
chore(frontend): migrate non-dashboard JS/JSX files to TypeScript
Migrates 15 core JavaScript/JSX files and 11 test files to TypeScript
as part of the ongoing frontend modernization effort.
Files migrated:
- src/utils/common.js → common.ts
- src/middleware/loggerMiddleware.js → loggerMiddleware.ts
- src/visualizations/presets/MainPreset.js → MainPreset.ts
- src/features/reports/ReportModal/actions.js → actions.ts
- src/features/reports/ReportModal/reducer.js → reducer.ts
- src/explore/exploreUtils/index.js → index.ts
- src/explore/reducers/exploreReducer.js → exploreReducer.ts
- src/explore/components/EmbedCodeContent.jsx → EmbedCodeContent.tsx
- src/explore/components/ExploreChartHeader/index.jsx → index.tsx
- src/explore/components/ExploreViewContainer/index.jsx → index.tsx
- src/explore/components/useExploreAdditionalActionsMenu/index.jsx → index.tsx
- src/components/Chart/chartAction.js → chartAction.ts
- src/components/Chart/ChartRenderer.jsx → ChartRenderer.tsx
- src/components/Datasource/components/DatasourceEditor/DatasourceEditor.jsx → DatasourceEditor.tsx
- src/components/Datasource/utils/index.js → index.ts
Key improvements:
- Added proper TypeScript interfaces for all components and functions
- Replaced PropTypes with TypeScript interfaces
- Added typed Redux actions and state interfaces
- Zero `any` types used throughout
Co-Authored-By: Claude Opus 4.5 <no...@anthropic.com>
---
...artRenderer.test.jsx => ChartRenderer.test.tsx} | 172 +++-
.../Chart/{ChartRenderer.jsx => ChartRenderer.tsx} | 285 ++++--
.../src/components/Chart/chartAction.js | 652 --------------
.../src/components/Chart/chartAction.ts | 983 +++++++++++++++++++++
.../{chartActions.test.js => chartActions.test.ts} | 280 ++++--
.../{DatasourceEditor.jsx => DatasourceEditor.tsx} | 394 +++++++--
.../Datasource/utils/{index.js => index.ts} | 116 ++-
...eContent.test.jsx => EmbedCodeContent.test.tsx} | 0
.../{EmbedCodeContent.jsx => EmbedCodeContent.tsx} | 26 +-
.../ExploreChartHeader/{index.jsx => index.tsx} | 80 +-
.../ExploreViewContainer/{index.jsx => index.tsx} | 155 +++-
.../{index.jsx => index.tsx} | 155 +++-
...exploreUtils.test.jsx => exploreUtils.test.tsx} | 21 +-
.../explore/exploreUtils/{index.js => index.ts} | 173 +++-
.../src/explore/reducers/exploreReducer.js | 375 --------
...ploreReducer.test.js => exploreReducer.test.ts} | 15 +-
.../src/explore/reducers/exploreReducer.ts | 597 +++++++++++++
.../src/features/reports/ReportModal/actions.js | 162 ----
.../src/features/reports/ReportModal/actions.ts | 243 +++++
.../src/features/reports/ReportModal/reducer.js | 99 ---
.../src/features/reports/ReportModal/reducer.ts | 147 +++
.../middleware/{logger.test.js => logger.test.ts} | 40 +-
.../{loggerMiddleware.js => loggerMiddleware.ts} | 97 +-
.../src/utils/{common.test.jsx => common.test.tsx} | 28 +-
.../src/utils/{common.js => common.ts} | 70 +-
.../presets/{MainPreset.js => MainPreset.ts} | 0
26 files changed, 3536 insertions(+), 1829 deletions(-)
diff --git a/superset-frontend/src/components/Chart/ChartRenderer.test.jsx b/superset-frontend/src/components/Chart/ChartRenderer.test.tsx
similarity index 59%
rename from superset-frontend/src/components/Chart/ChartRenderer.test.jsx
rename to superset-frontend/src/components/Chart/ChartRenderer.test.tsx
index 8ae9ac3b01..1d362f084a 100644
--- a/superset-frontend/src/components/Chart/ChartRenderer.test.jsx
+++ b/superset-frontend/src/components/Chart/ChartRenderer.test.tsx
@@ -21,13 +21,26 @@ import {
ChartMetadata,
getChartMetadataRegistry,
VizType,
+ JsonObject,
} 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,17 +52,51 @@ jest.mock(
() => () => <div data-test="mock-chart-context-menu" />,
);
-const requiredProps = {
+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: {},
- formData: { testControl: 'foo' },
+ datasource: {} as ChartRendererProps['datasource'],
+ formData: { testControl: 'foo', datasource: '', viz_type: VizType.Table },
latestQueryFormData: {
testControl: 'bar',
+ datasource: '',
+ viz_type: VizType.Table,
},
vizType: VizType.Table,
source: ChartSource.Dashboard,
+ actions: mockActions as ChartRendererProps['actions'],
};
+declare global {
+ interface Window {
+ featureFlags: Record<string, boolean>;
+ }
+}
+
beforeAll(() => {
window.featureFlags = { DRILL_TO_DETAIL: true };
});
@@ -59,22 +106,31 @@ afterAll(() => {
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',
+ datasource: '',
+ viz_type: VizType.Table,
+ }),
);
});
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 +145,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 +240,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 +249,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 +288,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 +328,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 +340,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 +367,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(
diff --git a/superset-frontend/src/components/Chart/ChartRenderer.jsx b/superset-frontend/src/components/Chart/ChartRenderer.tsx
similarity index 63%
rename from superset-frontend/src/components/Chart/ChartRenderer.jsx
rename to superset-frontend/src/components/Chart/ChartRenderer.tsx
index decee4d3b4..18a529db73 100644
--- a/superset-frontend/src/components/Chart/ChartRenderer.jsx
+++ b/superset-frontend/src/components/Chart/ChartRenderer.tsx
@@ -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,145 @@ import {
VizType,
isFeatureEnabled,
FeatureFlag,
+ QueryFormData,
+ AnnotationData,
+ DataMask,
+ QueryData,
+ JsonObject,
+ LatestQueryFormData,
+ AgGridChartState,
} 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';
-
-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,
-};
+import type { Datasource, ChartStatus } from 'src/explore/types';
+import type { Dispatch } from 'redux';
+import ChartContextMenu, {
+ ChartContextMenuRef,
+} from './ChartContextMenu/ChartContextMenu';
+
+// Types for filter values
+type FilterValue = string | number | boolean | null | undefined;
+
+// LegendState type based on ECharts
+interface LegendState {
+ [name: string]: boolean;
+}
+
+// Types for context menu filters
+interface ContextMenuFilter {
+ col: string;
+ val: FilterValue | FilterValue[];
+ op?: string;
+}
+
+// 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?: Record<string, FilterValue>;
+ 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?: ContextMenuFilter[],
+ ) => 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 +172,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 | null>;
+
+ 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 +209,9 @@ class ChartRenderer extends Component {
legendIndex: 0,
};
this.hasQueryResponseChange = false;
+ this.renderStartTime = 0;
- this.contextMenuRef = createRef();
+ this.contextMenuRef = createRef<ChartContextMenuRef | null>();
this.handleAddFilter = this.handleAddFilter.bind(this);
this.handleRenderSuccess = this.handleRenderSuccess.bind(this);
@@ -123,8 +234,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 +247,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 +268,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 +297,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 +312,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 +340,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 +365,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?: ContextMenuFilter[],
+ ): 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 +456,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 +465,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"
@@ -365,8 +498,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 +510,7 @@ class ChartRenderer extends Component {
<ChartContextMenu
ref={this.contextMenuRef}
id={chartId}
- formData={currentFormData}
+ formData={currentFormData as QueryFormData}
onSelection={this.handleContextMenuSelected}
onClose={this.handleContextMenuClosed}
/>
@@ -419,7 +553,4 @@ class ChartRenderer extends Component {
}
}
-ChartRenderer.propTypes = propTypes;
-ChartRenderer.defaultProps = defaultProps;
-
export default ChartRenderer;
diff --git a/superset-frontend/src/components/Chart/chartAction.js b/superset-frontend/src/components/Chart/chartAction.js
deleted file mode 100644
index 5c2453beda..0000000000
--- a/superset-frontend/src/components/Chart/chartAction.js
+++ /dev/null
@@ -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 },
- );
- }
-};
diff --git a/superset-frontend/src/components/Chart/chartAction.ts b/superset-frontend/src/components/Chart/chartAction.ts
new file mode 100644
index 0000000000..7f5e9850d5
--- /dev/null
+++ b/superset-frontend/src/components/Chart/chartAction.ts
@@ -0,0 +1,983 @@
+/**
+ * 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,
+ QueryFormData,
+ JsonObject,
+ QueryData,
+ AnnotationLayer,
+ DataMask,
+ DatasourceType,
+ LatestQueryFormData,
+} from '@superset-ui/core';
+import { t } from '@apache-superset/core/ui';
+import type { ControlStateMapping } from '@superset-ui/chart-controls';
+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';
+import type { Dispatch, Action, AnyAction } from 'redux';
+import type { ThunkAction, ThunkDispatch } from 'redux-thunk';
+import type { History } from 'history';
+import type { ChartState } from 'src/explore/types';
+
+// Types for the Redux state
+export interface ChartsState {
+ [key: string]: ChartState;
+}
+
+export interface CommonState {
+ conf: {
+ SUPERSET_WEBSERVER_TIMEOUT?: number;
+ [key: string]: unknown;
+ };
+}
+
+export interface DashboardInfoState {
+ common: CommonState;
+}
+
+export interface DataMaskState {
+ [key: number]: {
+ ownState?: JsonObject;
+ };
+}
+
+export interface ExploreState {
+ form_data: QueryFormData;
+ [key: string]: unknown;
+}
+
+export interface RootState {
+ charts: ChartsState;
+ common: CommonState;
+ dashboardInfo: DashboardInfoState;
+ dataMask: DataMaskState;
+ explore: ExploreState;
+}
+
+// Action types
+export const CHART_UPDATE_STARTED = 'CHART_UPDATE_STARTED' as const;
+export const CHART_UPDATE_SUCCEEDED = 'CHART_UPDATE_SUCCEEDED' as const;
+export const CHART_UPDATE_STOPPED = 'CHART_UPDATE_STOPPED' as const;
+export const CHART_UPDATE_FAILED = 'CHART_UPDATE_FAILED' as const;
+export const CHART_RENDERING_FAILED = 'CHART_RENDERING_FAILED' as const;
+export const CHART_RENDERING_SUCCEEDED = 'CHART_RENDERING_SUCCEEDED' as const;
+export const REMOVE_CHART = 'REMOVE_CHART' as const;
+export const ANNOTATION_QUERY_SUCCESS = 'ANNOTATION_QUERY_SUCCESS' as const;
+export const ANNOTATION_QUERY_STARTED = 'ANNOTATION_QUERY_STARTED' as const;
+export const ANNOTATION_QUERY_FAILED = 'ANNOTATION_QUERY_FAILED' as const;
+export const DYNAMIC_PLUGIN_CONTROLS_READY =
+ 'DYNAMIC_PLUGIN_CONTROLS_READY' as const;
+export const TRIGGER_QUERY = 'TRIGGER_QUERY' as const;
+export const RENDER_TRIGGERED = 'RENDER_TRIGGERED' as const;
+export const UPDATE_QUERY_FORM_DATA = 'UPDATE_QUERY_FORM_DATA' as const;
+export const UPDATE_CHART_ID = 'UPDATE_CHART_ID' as const;
+export const ADD_CHART = 'ADD_CHART' as const;
+export const POST_CHART_FORM_DATA = 'POST_CHART_FORM_DATA' as const;
+
+// Action interfaces
+export interface ChartUpdateStartedAction {
+ type: typeof CHART_UPDATE_STARTED;
+ queryController: AbortController;
+ latestQueryFormData: QueryFormData | LatestQueryFormData;
+ key: string | number;
+}
+
+export interface ChartUpdateSucceededAction {
+ type: typeof CHART_UPDATE_SUCCEEDED;
+ queriesResponse: QueryData[];
+ key: string | number;
+}
+
+export interface ChartUpdateStoppedAction {
+ type: typeof CHART_UPDATE_STOPPED;
+ key: string | number;
+}
+
+export interface ChartUpdateFailedAction {
+ type: typeof CHART_UPDATE_FAILED;
+ queriesResponse: QueryData[] | JsonObject[];
+ key: string | number;
+}
+
+export interface ChartRenderingFailedAction {
+ type: typeof CHART_RENDERING_FAILED;
+ error: string;
+ key: string | number;
+ stackTrace: string | null;
+}
+
+export interface ChartRenderingSucceededAction {
+ type: typeof CHART_RENDERING_SUCCEEDED;
+ key: string | number;
+}
+
+export interface RemoveChartAction {
+ type: typeof REMOVE_CHART;
+ key: string | number;
+}
+
+export interface AnnotationQuerySuccessAction {
+ type: typeof ANNOTATION_QUERY_SUCCESS;
+ annotation: AnnotationLayer;
+ queryResponse: { data: unknown } | JsonObject;
+ key: string | number;
+}
+
+export interface AnnotationQueryStartedAction {
+ type: typeof ANNOTATION_QUERY_STARTED;
+ annotation: AnnotationLayer;
+ queryController: AbortController;
+ key: string | number;
+}
+
+export interface AnnotationQueryFailedAction {
+ type: typeof ANNOTATION_QUERY_FAILED;
+ annotation: AnnotationLayer;
+ queryResponse: { error: string } | JsonObject;
+ key: string | number;
+}
+
+export interface DynamicPluginControlsReadyAction {
+ type: typeof DYNAMIC_PLUGIN_CONTROLS_READY;
+ key: string | number;
+ controlsState: ControlStateMapping;
+}
+
+export interface TriggerQueryAction {
+ type: typeof TRIGGER_QUERY;
+ value: boolean;
+ key: string | number;
+}
+
+export interface RenderTriggeredAction {
+ type: typeof RENDER_TRIGGERED;
+ value: number;
+ key: string | number;
+}
+
+export interface UpdateQueryFormDataAction {
+ type: typeof UPDATE_QUERY_FORM_DATA;
+ value: QueryFormData | LatestQueryFormData;
+ key: string | number;
+}
+
+export interface UpdateChartIdAction {
+ type: typeof UPDATE_CHART_ID;
+ newId: number;
+ key: string | number;
+}
+
+export interface AddChartAction {
+ type: typeof ADD_CHART;
+ chart: ChartState;
+ key: string | number;
+}
+
+export type ChartAction =
+ | ChartUpdateStartedAction
+ | ChartUpdateSucceededAction
+ | ChartUpdateStoppedAction
+ | ChartUpdateFailedAction
+ | ChartRenderingFailedAction
+ | ChartRenderingSucceededAction
+ | RemoveChartAction
+ | AnnotationQuerySuccessAction
+ | AnnotationQueryStartedAction
+ | AnnotationQueryFailedAction
+ | DynamicPluginControlsReadyAction
+ | TriggerQueryAction
+ | RenderTriggeredAction
+ | UpdateQueryFormDataAction
+ | UpdateChartIdAction
+ | AddChartAction;
+
+// Type for thunk actions
+export type ChartThunkDispatch = ThunkDispatch<RootState, undefined, AnyAction>;
+export type ChartThunkAction<R = void> = ThunkAction<
+ R,
+ RootState,
+ undefined,
+ AnyAction
+>;
+
+// Request params interface
+export interface RequestParams {
+ signal?: AbortSignal;
+ timeout?: number;
+ dashboard_id?: number;
+ mode?: string;
+ credentials?: RequestCredentials;
+ [key: string]: unknown;
+}
+
+// Query settings type
+export interface QuerySettings extends RequestParams {
+ url?: string;
+ postPayload?: { form_data: QueryFormData | LatestQueryFormData };
+ parseMethod?: string;
+ headers?: Record<string, string>;
+ body?: string;
+}
+
+// Chart data response type
+export interface ChartDataResponse {
+ response: Response;
+ json: {
+ result: QueryData[];
+ };
+}
+
+// API response type for chart data request
+export interface ChartDataRequestResponse {
+ response: Response;
+ json: {
+ result: QueryData[];
+ };
+}
+
+// getChartDataRequest params interface
+export interface GetChartDataRequestParams {
+ formData: QueryFormData | LatestQueryFormData;
+ setDataMask?: (dataMask: DataMask) => void;
+ resultFormat?: string;
+ resultType?: string;
+ force?: boolean;
+ method?: string;
+ requestParams?: RequestParams;
+ ownState?: JsonObject;
+}
+
+// runAnnotationQuery params interface
+export interface RunAnnotationQueryParams {
+ annotation: AnnotationLayer;
+ timeout?: number;
+ formData?: QueryFormData | LatestQueryFormData;
+ key?: string | number;
+ isDashboardRequest?: boolean;
+ force?: boolean;
+}
+
+// Datasource samples params interface
+export interface DatasourceSamplesSearchParams {
+ force: boolean;
+ datasource_type: DatasourceType;
+ datasource_id: number;
+ dashboard_id?: number;
+ per_page?: number;
+ page?: number;
+}
+
+// Action creators
+export function chartUpdateStarted(
+ queryController: AbortController,
+ latestQueryFormData: QueryFormData | LatestQueryFormData,
+ key: string | number,
+): ChartUpdateStartedAction {
+ return {
+ type: CHART_UPDATE_STARTED,
+ queryController,
+ latestQueryFormData,
+ key,
+ };
+}
+
+export function chartUpdateSucceeded(
+ queriesResponse: QueryData[],
+ key: string | number,
+): ChartUpdateSucceededAction {
+ return { type: CHART_UPDATE_SUCCEEDED, queriesResponse, key };
+}
+
+export function chartUpdateStopped(
+ key: string | number,
+): ChartUpdateStoppedAction {
+ return { type: CHART_UPDATE_STOPPED, key };
+}
+
+export function chartUpdateFailed(
+ queriesResponse: QueryData[] | JsonObject[],
+ key: string | number,
+): ChartUpdateFailedAction {
+ return { type: CHART_UPDATE_FAILED, queriesResponse, key };
+}
+
+export function chartRenderingFailed(
+ error: string,
+ key: string | number,
+ stackTrace: string | null,
+): ChartRenderingFailedAction {
+ return { type: CHART_RENDERING_FAILED, error, key, stackTrace };
+}
+
+export function chartRenderingSucceeded(
+ key: string | number,
+): ChartRenderingSucceededAction {
+ return { type: CHART_RENDERING_SUCCEEDED, key };
+}
+
+export function removeChart(key: string | number): RemoveChartAction {
+ return { type: REMOVE_CHART, key };
+}
+
+export function annotationQuerySuccess(
+ annotation: AnnotationLayer,
+ queryResponse: { data: unknown } | JsonObject,
+ key: string | number,
+): AnnotationQuerySuccessAction {
+ return { type: ANNOTATION_QUERY_SUCCESS, annotation, queryResponse, key };
+}
+
+export function annotationQueryStarted(
+ annotation: AnnotationLayer,
+ queryController: AbortController,
+ key: string | number,
+): AnnotationQueryStartedAction {
+ return { type: ANNOTATION_QUERY_STARTED, annotation, queryController, key };
+}
+
+export function annotationQueryFailed(
+ annotation: AnnotationLayer,
+ queryResponse: { error: string } | JsonObject,
+ key: string | number,
+): AnnotationQueryFailedAction {
+ return { type: ANNOTATION_QUERY_FAILED, annotation, queryResponse, key };
+}
+
+export const dynamicPluginControlsReady =
+ (): ChartThunkAction =>
+ (dispatch: Dispatch, getState: () => RootState): void => {
+ 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: QueryFormData | LatestQueryFormData,
+ resultFormat: string,
+ resultType: string,
+ force: boolean,
+ method = 'POST',
+ requestParams: RequestParams = {},
+ parseMethod?: string,
+): Promise<ChartDataRequestResponse> => {
+ 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: QuerySettings = {
+ ...requestParams,
+ url,
+ postPayload: { form_data: formData },
+ parseMethod,
+ };
+
+ return SupersetClient.post(querySettings).then(
+ ({ json, response }: { json: JsonObject; response: 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: QueryFormData | LatestQueryFormData,
+ resultFormat: string,
+ resultType: string,
+ force: boolean,
+ requestParams: RequestParams,
+ setDataMask: (dataMask: DataMask) => void,
+ ownState: JsonObject,
+ parseMethod?: string,
+): Promise<ChartDataRequestResponse> => {
+ 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: Record<string, string | number | boolean> = {};
+ 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: 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 = {},
+}: GetChartDataRequestParams): Promise<ChartDataRequestResponse> {
+ let querySettings: RequestParams = {
+ ...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,
+}: RunAnnotationQueryParams): ChartThunkAction<Promise<void | Action>> {
+ return async function (
+ dispatch: ChartThunkDispatch,
+ getState: () => RootState,
+ ): Promise<void | Action> {
+ const { charts, common } = getState();
+ const sliceKey = key || Object.keys(charts)[0];
+ const queryTimeout = timeout || common.conf.SUPERSET_WEBSERVER_TIMEOUT || 0;
+
+ // make a copy of formData, not modifying original formData
+ const fd: JsonObject = {
+ ...(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: JsonObject = 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: AnnotationLayer) => it.name === annotation.name,
+ );
+ if (annotationIndex !== undefined && annotationIndex >= 0) {
+ fd.annotation_layers[annotationIndex].overrides = sliceFormData;
+ }
+
+ const payload = await buildV1ChartDataPayload({
+ formData: fd as QueryFormData,
+ force,
+ resultFormat: 'json',
+ resultType: 'full',
+ });
+
+ return SupersetClient.post({
+ url,
+ signal,
+ timeout: queryTimeout * 1000,
+ headers: { 'Content-Type': 'application/json' },
+ jsonPayload: payload,
+ })
+ .then(({ json }: { json: JsonObject }) => {
+ const data = json?.result?.[0]?.annotation_data?.[annotation.name];
+ return dispatch(annotationQuerySuccess(annotation, { data }, sliceKey));
+ })
+ .catch((response: JsonObject) =>
+ 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 function triggerQuery(
+ value = true,
+ key: string | number,
+): TriggerQueryAction {
+ return { type: TRIGGER_QUERY, value, key };
+}
+
+// this action is used for forced re-render without fetch data
+export function renderTriggered(
+ value: number,
+ key: string | number,
+): RenderTriggeredAction {
+ return { type: RENDER_TRIGGERED, value, key };
+}
+
+export function updateQueryFormData(
+ value: QueryFormData | LatestQueryFormData,
+ key: string | number,
+): UpdateQueryFormDataAction {
+ 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 function updateChartId(
+ newId: number,
+ key: string | number = 0,
+): UpdateChartIdAction {
+ return { type: UPDATE_CHART_ID, newId, key };
+}
+
+export function addChart(
+ chart: ChartState,
+ key: string | number,
+): AddChartAction {
+ return { type: ADD_CHART, chart, key };
+}
+
+export function handleChartDataResponse(
+ response: Response,
+ json: { result: QueryData[] },
+ useLegacyApi?: boolean,
+): Promise<QueryData[]> | QueryData[] {
+ 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: QueryFormData | LatestQueryFormData,
+ force = false,
+ timeout?: number,
+ key?: string | number,
+ dashboardId?: number,
+ ownState?: JsonObject,
+): ChartThunkAction<Promise<unknown[]>> {
+ return async (
+ dispatch: ChartThunkDispatch,
+ getState: () => RootState,
+ ): Promise<unknown[]> => {
+ const state = getState();
+ const logStart = Logger.getTimestamp();
+ const controller = new AbortController();
+ const prevController = key ? state.charts?.[key]?.queryController : null;
+ const queryTimeout =
+ timeout || state.common.conf.SUPERSET_WEBSERVER_TIMEOUT || 0;
+
+ const requestParams: RequestParams = {
+ signal: controller.signal,
+ timeout: queryTimeout * 1000,
+ };
+ if (dashboardId) requestParams.dashboard_id = dashboardId;
+
+ const setDataMask = (dataMask: DataMask): void => {
+ dispatch(updateDataMask(formData.slice_id, dataMask));
+ };
+ dispatch(chartUpdateStarted(controller, formData, key as string | number));
+ /**
+ * 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 as QueryData[]).forEach(
+ (resultItem: QueryData & { applied_filters?: JsonObject[] }) =>
+ 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 as QueryData[], key as number),
+ );
+ })
+ .catch(
+ (
+ response: Error & {
+ name?: string;
+ statusText?: string;
+ },
+ ) => {
+ // 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 as string | number));
+ }
+
+ if (isFeatureEnabled(FeatureFlag.GlobalAsyncQueries)) {
+ // In async mode we just pass the raw error response through
+ return dispatch(
+ chartUpdateFailed([response as JsonObject], key as string | number),
+ );
+ }
+
+ const appendErrorLog = (
+ errorDetails: string | undefined,
+ isCached?: boolean,
+ ): void => {
+ 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: JsonObject) => {
+ if ((response as { statusText?: string }).statusText === 'timeout') {
+ appendErrorLog('timeout');
+ } else {
+ appendErrorLog(parsedResponse.error, parsedResponse.is_cached);
+ }
+ return dispatch(
+ chartUpdateFailed([parsedResponse], key as string | number),
+ );
+ },
+ );
+ },
+ );
+
+ // only retrieve annotations when calling the legacy API
+ const annotationLayers: AnnotationLayer[] = useLegacyApi
+ ? (formData.annotation_layers as AnnotationLayer[]) || []
+ : [];
+ const isDashboardRequest = (dashboardId ?? 0) > 0;
+
+ return Promise.all([
+ chartDataRequestCaught,
+ dispatch(triggerQuery(false, key as string | number)),
+ dispatch(updateQueryFormData(formData, key as string | number)),
+ ...annotationLayers.map((annotation: AnnotationLayer) =>
+ dispatch(
+ runAnnotationQuery({
+ annotation,
+ timeout,
+ formData,
+ key,
+ isDashboardRequest,
+ force,
+ }),
+ ),
+ ),
+ ]);
+ };
+}
+
+export function postChartFormData(
+ formData: QueryFormData | LatestQueryFormData,
+ force = false,
+ timeout?: number,
+ key?: string | number,
+ dashboardId?: number,
+ ownState?: JsonObject,
+): ChartThunkAction<Promise<unknown[]>> {
+ return exploreJSON(formData, force, timeout, key, dashboardId, ownState);
+}
+
+export function redirectSQLLab(
+ formData: QueryFormData | LatestQueryFormData,
+ history?: History,
+): ChartThunkAction {
+ return (dispatch: ChartThunkDispatch): void => {
+ 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: string | number,
+ force: boolean,
+ dashboardId?: number,
+): ChartThunkAction {
+ return (dispatch: ChartThunkDispatch, getState: () => RootState): void => {
+ 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: DatasourceType,
+ datasourceId: number,
+ force: boolean,
+ jsonPayload: JsonObject,
+ perPage?: number,
+ page?: number,
+ dashboardId?: number,
+): Promise<JsonObject> => {
+ try {
+ const searchParams: DatasourceSamplesSearchParams = {
+ 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 },
+ );
+ }
+};
diff --git a/superset-frontend/src/components/Chart/chartActions.test.js b/superset-frontend/src/components/Chart/chartActions.test.ts
similarity index 66%
rename from superset-frontend/src/components/Chart/chartActions.test.js
rename to superset-frontend/src/components/Chart/chartActions.test.ts
index 18c28de4ab..22fcae6c87 100644
--- a/superset-frontend/src/components/Chart/chartActions.test.js
+++ b/superset-frontend/src/components/Chart/chartActions.test.ts
@@ -18,13 +18,16 @@
*/
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,
} from '@superset-ui/core';
import { LOG_EVENT } from 'src/logger/actions';
import * as exploreUtils from 'src/explore/exploreUtils';
@@ -37,10 +40,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,17 +80,26 @@ 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;
-
- const setupDefaultFetchMock = () => {
+ let dispatch: SinonSpy;
+ let getExploreUrlStub: SinonStub;
+ let getChartDataUriStub: SinonStub;
+ let buildV1ChartDataPayloadStub: SinonStub;
+ let waitForAsyncDataStub: SinonStub;
+ let fakeMetadata: { useLegacyApi?: boolean; viz_type?: string };
+
+ const setupDefaultFetchMock = (): void => {
fetchMock.post(MOCK_URL, { json: {} }, { overwriteRoutes: true });
};
@@ -87,7 +116,9 @@ 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({
@@ -96,32 +127,38 @@ describe('chart actions', () => {
result_format: 'json',
});
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 ReturnType<typeof getChartMetadataRegistry>,
+ );
+ mockedGetChartBuildQueryRegistry.mockImplementation(
+ () =>
+ ({
+ get: () => () => ({
+ some_param: 'fake query!',
+ result_type: 'full',
+ result_format: 'json',
+ }),
+ }) as ReturnType<typeof getChartBuildQueryRegistry>,
+ );
waitForAsyncDataStub = sinon
.stub(asyncEvent, 'waitForAsyncData')
- .callsFake(data => Promise.resolve(data));
+ .callsFake((data: unknown) => Promise.resolve(data));
});
test('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,
@@ -138,7 +175,7 @@ describe('chart actions', () => {
const getChartDataRequestSpy = jest
.spyOn(actions, 'getChartDataRequest')
.mockResolvedValue({
- response: { status: 200 },
+ response: { status: 200 } as Response,
json: { result: [] },
});
const handleChartDataResponseSpy = jest
@@ -146,14 +183,24 @@ 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, () => {}]);
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 () => actions.RootState,
+ );
expect(abortSpy).not.toHaveBeenCalled();
expect(oldController.signal.aborted).toBe(false);
@@ -181,7 +228,9 @@ describe('chart actions', () => {
fetchMock.resetHistory();
waitForAsyncDataStub.restore();
- global.featureFlags = {
+ (
+ global as unknown as { featureFlags: Record<string, boolean> }
+ ).featureFlags = {
[FeatureFlag.GlobalAsyncQueries]: false,
};
});
@@ -193,11 +242,19 @@ 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 () => actions.RootState,
+ );
expect(fetchMock.calls(MOCK_URL)).toHaveLength(1);
- expect(fetchMock.calls(MOCK_URL)[0][1].body).toBe(
+ expect(fetchMock.calls(MOCK_URL)[0][1]?.body).toBe(
JSON.stringify({
some_param: 'fake query!',
result_type: 'full',
@@ -219,39 +276,55 @@ describe('chart actions', () => {
.callsFake(() => URI(mockBigIntUrl));
const { json } = await actions.getChartDataRequest({
- formData: fakeMetadata,
+ formData: fakeMetadata as QueryFormData,
});
expect(fetchMock.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]);
});
@@ -264,9 +337,12 @@ describe('chart actions', () => {
});
test('should dispatch CHART_UPDATE_STARTED action before the query', () => {
- const actionThunk = actions.postChartFormData({});
+ const actionThunk = actions.postChartFormData({} as QueryFormData);
- return actionThunk(dispatch, mockGetState).then(() => {
+ return actionThunk(
+ dispatch as unknown as actions.ChartThunkDispatch,
+ mockGetState as () => actions.RootState,
+ ).then(() => {
// chart update, trigger query, update form data, success
expect(dispatch.callCount).toBe(5);
expect(fetchMock.calls(MOCK_URL)).toHaveLength(1);
@@ -275,8 +351,11 @@ 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({} as QueryFormData);
+ return actionThunk(
+ dispatch as unknown as actions.ChartThunkDispatch,
+ mockGetState as () => actions.RootState,
+ ).then(() => {
// chart update, trigger query, update form data, success
expect(dispatch.callCount).toBe(5);
expect(fetchMock.calls(MOCK_URL)).toHaveLength(1);
@@ -285,8 +364,11 @@ 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({} as QueryFormData);
+ return actionThunk(
+ dispatch as unknown as actions.ChartThunkDispatch,
+ mockGetState as () => actions.RootState,
+ ).then(() => {
// chart update, trigger query, update form data, success
expect(dispatch.callCount).toBe(5);
expect(fetchMock.calls(MOCK_URL)).toHaveLength(1);
@@ -295,8 +377,11 @@ describe('chart actions', () => {
});
test('should dispatch logEvent async action', () => {
- const actionThunk = actions.postChartFormData({});
- return actionThunk(dispatch, mockGetState).then(() => {
+ const actionThunk = actions.postChartFormData({} as QueryFormData);
+ return actionThunk(
+ dispatch as unknown as actions.ChartThunkDispatch,
+ mockGetState as () => actions.RootState,
+ ).then(() => {
// chart update, trigger query, update form data, success
expect(dispatch.callCount).toBe(5);
expect(fetchMock.calls(MOCK_URL)).toHaveLength(1);
@@ -309,8 +394,11 @@ describe('chart actions', () => {
});
test('should dispatch CHART_UPDATE_SUCCEEDED action upon success', () => {
- const actionThunk = actions.postChartFormData({});
- return actionThunk(dispatch, mockGetState).then(() => {
+ const actionThunk = actions.postChartFormData({} as QueryFormData);
+ return actionThunk(
+ dispatch as unknown as actions.ChartThunkDispatch,
+ mockGetState as () => actions.RootState,
+ ).then(() => {
// chart update, trigger query, update form data, success
expect(dispatch.callCount).toBe(5);
expect(fetchMock.calls(MOCK_URL)).toHaveLength(1);
@@ -325,9 +413,16 @@ 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 () => actions.RootState,
+ ).then(() => {
// chart update, trigger query, update form data, fail
expect(fetchMock.calls(MOCK_URL)).toHaveLength(1);
expect(dispatch.callCount).toBe(5);
@@ -344,9 +439,16 @@ 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 () => actions.RootState,
+ ).then(() => {
// chart update, trigger query, update form data, fail
expect(dispatch.callCount).toBe(5);
const updateFailedAction = dispatch.args[4][0];
@@ -365,11 +467,18 @@ 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 () => actions.RootState,
+ ).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);
@@ -391,11 +500,13 @@ describe('chart actions', () => {
.callsFake(() => mockBigIntUrl);
const { json } = await actions.getChartDataRequest({
- formData: fakeMetadata,
+ formData: fakeMetadata as QueryFormData,
});
expect(fetchMock.calls(mockBigIntUrl)).toHaveLength(1);
- expect(json.result[0].value.toString()).toEqual(expectedBigNumber);
+ expect((json.result[0] as JsonObject).value.toString()).toEqual(
+ expectedBigNumber,
+ );
});
});
@@ -407,7 +518,7 @@ describe('chart actions', () => {
});
test('should dispatch annotationQueryStarted and annotationQuerySuccess on successful query', async () => {
- const annotation = {
+ const annotation: AnnotationLayer = {
name: 'Holidays',
annotationType: 'EVENT',
sourceType: 'NATIVE',
@@ -432,7 +543,7 @@ describe('chart actions', () => {
const postSpy = jest.spyOn(SupersetClient, 'post');
postSpy.mockImplementation(() =>
- Promise.resolve({ json: { result: [] } }),
+ Promise.resolve({ json: { result: [] } } as { json: JsonObject }),
);
const buildV1ChartDataPayloadSpy = jest.spyOn(
exploreUtils,
@@ -440,7 +551,10 @@ describe('chart actions', () => {
);
const queryFunc = actions.runAnnotationQuery({ annotation, key });
- await queryFunc(mockDispatch, mockGetState);
+ await queryFunc(
+ mockDispatch as unknown as actions.ChartThunkDispatch,
+ mockGetState as () => actions.RootState,
+ );
expect(buildV1ChartDataPayloadSpy).toHaveBeenCalledWith({
formData: {
@@ -464,9 +578,11 @@ 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 { json: JsonObject }),
+ );
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);
@@ -476,19 +592,19 @@ describe('chart actions timeout', () => {
value: 'annotationValue',
sourceType: 'Event',
overrides: {},
- },
+ } as AnnotationLayer,
timeout,
- formData,
+ formData: formData as QueryFormData,
key,
}),
);
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);
@@ -496,8 +612,10 @@ 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 { json: JsonObject }),
+ );
+ const formData: Partial<QueryFormData> = { datasource: 'table__1' }; // Set the formData here
const key = 'chartKey'; // Set the chart key here
const store = mockStore(initialState);
@@ -507,19 +625,21 @@ describe('chart actions timeout', () => {
value: 'annotationValue',
sourceType: 'Event',
overrides: {},
- },
- undefined,
- formData,
+ } as AnnotationLayer,
+ timeout: undefined,
+ formData: formData as QueryFormData,
key,
}),
);
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 { 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);
diff --git a/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.jsx b/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx
similarity index 90%
rename from superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.jsx
rename to superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx
index 1412b4cedf..69df2a5e86 100644
--- a/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.jsx
+++ b/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx
@@ -17,9 +17,10 @@
* under the License.
*/
import rison from 'rison';
-import { PureComponent, useCallback } from 'react';
-import PropTypes from 'prop-types';
-import { connect } from 'react-redux';
+import { PureComponent, useCallback, ReactNode } from 'react';
+import { connect, ConnectedProps } from 'react-redux';
+import type { SupersetTheme, JsonObject } from '@superset-ui/core';
+import type { Dispatch } from 'redux';
import { Radio } from '@superset-ui/core/components/Radio';
import {
isFeatureEnabled,
@@ -82,6 +83,191 @@ import DatasetUsageTab from './components/DatasetUsageTab';
const extensionsRegistry = getExtensionsRegistry();
+// Type definitions
+
+interface Owner {
+ id?: number;
+ value?: number;
+ label?: string;
+ first_name?: string;
+ last_name?: string;
+}
+
+interface Currency {
+ symbol?: string;
+ symbolPosition?: string;
+}
+
+interface Metric {
+ id?: number;
+ metric_name: string;
+ expression?: string;
+ verbose_name?: string;
+ description?: string;
+ d3format?: string;
+ currency?: Currency;
+ certified_by?: string;
+ certification_details?: string;
+ warning_markdown?: string;
+ extra?: string;
+}
+
+interface Column {
+ id?: number;
+ column_name: string;
+ verbose_name?: string;
+ description?: string;
+ expression?: string;
+ filterable?: boolean;
+ groupby?: boolean;
+ is_dttm?: boolean;
+ type?: string;
+ advanced_data_type?: string;
+ python_date_format?: string;
+ json?: string;
+ certified_by?: string;
+ certification_details?: string;
+ is_certified?: boolean;
+}
+
+interface Database {
+ id: number;
+ database_name?: string;
+ backend?: string;
+}
+
+interface DatasourceObject {
+ id?: number;
+ datasource_name?: string;
+ database?: Database;
+ catalog?: string;
+ schema?: string;
+ table_name?: string;
+ sql?: string;
+ columns: Column[];
+ metrics?: Metric[];
+ owners: Owner[];
+ main_dttm_col?: string;
+ filter_select_enabled?: boolean;
+ fetch_values_predicate?: string;
+ description?: string;
+ default_endpoint?: string;
+ extra?: string;
+ datasource_type?: string;
+ type?: string;
+ offset?: number;
+ cache_timeout?: number;
+ normalize_columns?: boolean;
+ always_filter_main_dttm?: boolean;
+ template_params?: string;
+}
+
+interface DatasourceEditorOwnProps {
+ datasource: DatasourceObject;
+ onChange?: (datasource: DatasourceObject, errors: string[]) => void;
+ addSuccessToast: (msg: string) => void;
+ addDangerToast: (msg: string) => void;
+ setIsEditing?: (isEditing: boolean) => void;
+ currencies?: string[];
+ theme?: SupersetTheme;
+}
+
+interface DatabaseState {
+ clientId?: string;
+ queryResponse?: {
+ results?: {
+ columns?: Array<{ name: string; type: string }>;
+ data?: Array<Record<string, unknown>>;
+ };
+ };
+ queryRunning?: boolean;
+}
+
+interface RootState {
+ database?: DatabaseState;
+}
+
+interface ChartUsageData {
+ id: number;
+ slice_name: string;
+ url: string;
+ certified_by?: string;
+ certification_details?: string;
+ description?: string;
+ owners?: Owner[];
+ changed_on_delta_humanized?: string;
+ changed_on?: string;
+ changed_by?: {
+ first_name?: string;
+ last_name?: string;
+ id?: number;
+ };
+ dashboards?: Array<{
+ id: number;
+ dashboard_title: string;
+ url: string;
+ }>;
+}
+
+interface DatasourceEditorState {
+ datasource: DatasourceObject;
+ errors: string[];
+ isSqla: boolean;
+ isEditMode: boolean;
+ databaseColumns: Column[];
+ calculatedColumns: Column[];
+ metadataLoading: boolean;
+ activeTabKey: string;
+ datasourceType: string;
+ usageCharts: ChartUsageData[];
+ usageChartsCount: number;
+}
+
+interface AbortControllers {
+ formatQuery: AbortController | null;
+ formatSql: AbortController | null;
+ syncMetadata: AbortController | null;
+ fetchUsageData: AbortController | null;
+}
+
+interface CurrencyOption {
+ value: string;
+ label: string;
+}
+
+// Component props interfaces
+interface CollectionTabTitleProps {
+ title: string;
+ collection?: unknown[];
+}
+
+interface ColumnCollectionTableProps {
+ columns: Column[];
+ datasource: DatasourceObject;
+ onColumnsChange: (columns: Column[]) => void;
+ onDatasourceChange: (datasource: DatasourceObject) => void;
+ editableColumnName?: boolean;
+ showExpression?: boolean;
+ allowAddItem?: boolean;
+ allowEditDataType?: boolean;
+ itemGenerator?: () => Partial<Column>;
+ columnLabelTooltips?: Record<string, string>;
+}
+
+interface StackedFieldProps {
+ label: string;
+ formElement: ReactNode;
+}
+
+interface FormContainerProps {
+ children: ReactNode;
+}
+
+interface OwnersSelectorProps {
+ datasource: DatasourceObject;
+ onChange: (owners: Owner[]) => void;
+}
+
const DatasourceContainer = styled.div`
.change-warning {
margin: 16px 10px 0;
@@ -167,9 +353,10 @@ const StyledButtonWrapper = styled.span`
`}
`;
-const checkboxGenerator = (d, onChange) => (
- <CheckboxControl value={d} onChange={onChange} />
-);
+const checkboxGenerator = (
+ d: boolean,
+ onChange: (value: boolean) => void,
+): ReactNode => <CheckboxControl value={d} onChange={onChange} />;
const DATA_TYPES = [
{ value: 'STRING', label: t('STRING') },
{ value: 'NUMERIC', label: t('NUMERIC') },
@@ -196,7 +383,10 @@ DATASOURCE_TYPES_ARR.forEach(o => {
DATASOURCE_TYPES[o.key] = o;
});
-function CollectionTabTitle({ title, collection }) {
+function CollectionTabTitle({
+ title,
+ collection,
+}: CollectionTabTitleProps): ReactNode {
return (
<div
css={{ display: 'flex', alignItems: 'center' }}
@@ -208,23 +398,22 @@ function CollectionTabTitle({ title, collection }) {
);
}
-CollectionTabTitle.propTypes = {
- title: PropTypes.string,
- collection: PropTypes.array,
-};
-
function ColumnCollectionTable({
columns,
datasource,
onColumnsChange,
onDatasourceChange,
- editableColumnName,
- showExpression,
- allowAddItem,
- allowEditDataType,
- itemGenerator,
+ editableColumnName = false,
+ showExpression = false,
+ allowAddItem = false,
+ allowEditDataType = false,
+ itemGenerator = () => ({
+ column_name: t('<new column>'),
+ filterable: true,
+ groupby: true,
+ }),
columnLabelTooltips,
-}) {
+}: ColumnCollectionTableProps): ReactNode {
return (
<CollectionTable
tableColumns={
@@ -527,30 +716,8 @@ function ColumnCollectionTable({
/>
);
}
-ColumnCollectionTable.propTypes = {
- columns: PropTypes.array.isRequired,
- datasource: PropTypes.object.isRequired,
- onColumnsChange: PropTypes.func.isRequired,
- onDatasourceChange: PropTypes.func.isRequired,
- editableColumnName: PropTypes.bool,
- showExpression: PropTypes.bool,
- allowAddItem: PropTypes.bool,
- allowEditDataType: PropTypes.bool,
- itemGenerator: PropTypes.func,
-};
-ColumnCollectionTable.defaultProps = {
- editableColumnName: false,
- showExpression: false,
- allowAddItem: false,
- allowEditDataType: false,
- itemGenerator: () => ({
- column_name: t('<new column>'),
- filterable: true,
- groupby: true,
- }),
-};
-function StackedField({ label, formElement }) {
+function StackedField({ label, formElement }: StackedFieldProps): ReactNode {
return (
<div>
<div>
@@ -561,12 +728,7 @@ function StackedField({ label, formElement }) {
);
}
-StackedField.propTypes = {
- label: PropTypes.string,
- formElement: PropTypes.node,
-};
-
-function FormContainer({ children }) {
+function FormContainer({ children }: FormContainerProps): ReactNode {
return (
<Card padded style={{ backgroundColor: themeObject.theme.colorBgLayout }}>
{children}
@@ -574,38 +736,31 @@ function FormContainer({ children }) {
);
}
-FormContainer.propTypes = {
- children: PropTypes.node,
-};
-
-const propTypes = {
- datasource: PropTypes.object.isRequired,
- onChange: PropTypes.func,
- addSuccessToast: PropTypes.func.isRequired,
- addDangerToast: PropTypes.func.isRequired,
- setIsEditing: PropTypes.func,
-};
-
-const defaultProps = {
- onChange: () => {},
- setIsEditing: () => {},
-};
-
-function OwnersSelector({ datasource, onChange }) {
- const loadOptions = useCallback((search = '', page, pageSize) => {
- const query = rison.encode({ filter: search, page, page_size: pageSize });
- return SupersetClient.get({
- endpoint: `/api/v1/dataset/related/owners?q=${query}`,
- }).then(response => ({
- data: response.json.result
- .filter(item => item.extra.active)
- .map(item => ({
- value: item.value,
- label: item.text,
- })),
- totalCount: response.json.count,
- }));
- }, []);
+function OwnersSelector({
+ datasource,
+ onChange,
+}: OwnersSelectorProps): ReactNode {
+ const loadOptions = useCallback(
+ (
+ search = '',
+ page: number,
+ pageSize: number,
+ ): Promise<{ data: Owner[]; totalCount: number }> => {
+ const query = rison.encode({ filter: search, page, page_size: pageSize });
+ return SupersetClient.get({
+ endpoint: `/api/v1/dataset/related/owners?q=${query}`,
+ }).then(response => ({
+ data: (response.json.result as Array<JsonObject>)
+ .filter(item => item.extra.active)
+ .map(item => ({
+ value: item.value as number,
+ label: item.text as string,
+ })),
+ totalCount: response.json.count,
+ }));
+ },
+ [],
+ );
return (
<AsyncSelect
@@ -623,8 +778,64 @@ function OwnersSelector({ datasource, onChange }) {
const ResultTable =
extensionsRegistry.get('sqleditor.extension.resultTable') ?? FilterableTable;
-class DatasourceEditor extends PureComponent {
- constructor(props) {
+// Redux connector types
+interface QueryPayload {
+ client_id?: string;
+ database_id: number;
+ runAsync: boolean;
+ catalog?: string;
+ schema?: string;
+ sql: string;
+ tmp_table_name: string;
+ select_as_cta: boolean;
+ ctas_method: string;
+ queryLimit: number;
+ expand_data: boolean;
+}
+
+interface FormatQueryResponse {
+ json: {
+ result: string;
+ };
+}
+
+const mapDispatchToProps = (dispatch: Dispatch) => ({
+ runQuery: (payload: QueryPayload) => dispatch(executeQuery(payload)),
+ resetQuery: () => dispatch(resetDatabaseState()),
+ formatQuery: (
+ sql: string,
+ options: { signal: AbortSignal },
+ ): Promise<FormatQueryResponse> => dispatch(formatQuery(sql, options)),
+});
+
+const mapStateToProps = (state: RootState) => ({
+ database: state?.database,
+});
+
+const connector = connect(mapStateToProps, mapDispatchToProps);
+type PropsFromRedux = ConnectedProps<typeof connector>;
+
+type DatasourceEditorProps = DatasourceEditorOwnProps &
+ PropsFromRedux & {
+ theme?: SupersetTheme;
+ };
+
+class DatasourceEditor extends PureComponent<
+ DatasourceEditorProps,
+ DatasourceEditorState
+> {
+ private isComponentMounted: boolean;
+
+ private abortControllers: AbortControllers;
+
+ private currencies: CurrencyOption[];
+
+ static defaultProps = {
+ onChange: () => {},
+ setIsEditing: () => {},
+ };
+
+ constructor(props: DatasourceEditorProps) {
super(props);
this.state = {
datasource: {
@@ -1999,7 +2210,7 @@ class DatasourceEditor extends PureComponent {
);
}
- componentDidUpdate(prevProps) {
+ componentDidUpdate(prevProps: DatasourceEditorProps): void {
// Preserve calculated columns order when props change to prevent jumping
if (this.props.datasource !== prevProps.datasource) {
const newCalculatedColumns = this.props.datasource.columns.filter(
@@ -2009,8 +2220,8 @@ class DatasourceEditor extends PureComponent {
if (newCalculatedColumns.length === currentCalculatedColumns.length) {
// Try to preserve the order by matching with existing calculated columns
- const orderedCalculatedColumns = [];
- const usedIds = new Set();
+ const orderedCalculatedColumns: Column[] = [];
+ const usedIds = new Set<string | number>();
// First, add existing columns in their current order
currentCalculatedColumns.forEach(currentCol => {
@@ -2069,19 +2280,6 @@ class DatasourceEditor extends PureComponent {
}
}
-DatasourceEditor.defaultProps = defaultProps;
-DatasourceEditor.propTypes = propTypes;
-
const DataSourceComponent = withTheme(DatasourceEditor);
-const mapDispatchToProps = dispatch => ({
- runQuery: payload => dispatch(executeQuery(payload)),
- resetQuery: () => dispatch(resetDatabaseState()),
- formatQuery: (sql, options) => dispatch(formatQuery(sql, options)),
-});
-const mapStateToProps = state => ({
- database: state?.database,
-});
-export default withToasts(
- connect(mapStateToProps, mapDispatchToProps)(DataSourceComponent),
-);
+export default withToasts(connector(DataSourceComponent));
diff --git a/superset-frontend/src/components/Datasource/utils/index.js b/superset-frontend/src/components/Datasource/utils/index.ts
similarity index 63%
rename from superset-frontend/src/components/Datasource/utils/index.js
rename to superset-frontend/src/components/Datasource/utils/index.ts
index 94c2db1244..110879788f 100644
--- a/superset-frontend/src/components/Datasource/utils/index.js
+++ b/superset-frontend/src/components/Datasource/utils/index.ts
@@ -16,26 +16,94 @@
* 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>) => Partial<T>,
+): 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>));
}
- if (newChild && newChild.props && newChild.props.children) {
+ if (
+ isValidElement(newChild) &&
+ newChild.props &&
+ (newChild.props as { children?: ReactNode }).children
+ ) {
newChild = cloneElement(newChild, {
children: recurseReactClone(
- newChild.props.children,
+ (newChild.props as { children: ReactNode }).children,
type,
propExtender,
),
@@ -45,15 +113,22 @@ export function recurseReactClone(children, type, propExtender) {
});
}
-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 +212,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 +240,5 @@ export async function fetchSyncedColumns(datasource, signal) {
params,
)}`;
const { json } = await SupersetClient.get({ endpoint, signal });
- return json;
+ return json as ColumnMetadata[];
}
diff --git a/superset-frontend/src/explore/components/EmbedCodeContent.test.jsx b/superset-frontend/src/explore/components/EmbedCodeContent.test.tsx
similarity index 100%
rename from superset-frontend/src/explore/components/EmbedCodeContent.test.jsx
rename to superset-frontend/src/explore/components/EmbedCodeContent.test.tsx
diff --git a/superset-frontend/src/explore/components/EmbedCodeContent.jsx b/superset-frontend/src/explore/components/EmbedCodeContent.tsx
similarity index 88%
rename from superset-frontend/src/explore/components/EmbedCodeContent.jsx
rename to superset-frontend/src/explore/components/EmbedCodeContent.tsx
index 5668611d78..269b771d72 100644
--- a/superset-frontend/src/explore/components/EmbedCodeContent.jsx
+++ b/superset-frontend/src/explore/components/EmbedCodeContent.tsx
@@ -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 { QueryFormData } 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?: QueryFormData;
+ 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);
@@ -51,7 +67,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 +114,7 @@ const EmbedCodeContent = ({ formData, addDangerToast }) => {
name="embedCode"
disabled={!html}
value={text}
- rows="4"
+ rows={4}
readOnly
css={theme => css`
resize: vertical;
diff --git a/superset-frontend/src/explore/components/ExploreChartHeader/index.jsx b/superset-frontend/src/explore/components/ExploreChartHeader/index.tsx
similarity index 85%
rename from superset-frontend/src/explore/components/ExploreChartHeader/index.jsx
rename to superset-frontend/src/explore/components/ExploreChartHeader/index.tsx
index 8be2bea51b..a4d0f05ede 100644
--- a/superset-frontend/src/explore/components/ExploreChartHeader/index.jsx
+++ b/superset-frontend/src/explore/components/ExploreChartHeader/index.tsx
@@ -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,
@@ -29,8 +29,7 @@ import {
import { AlteredSliceTag } from 'src/components';
import { SupersetClient } 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';
@@ -42,35 +41,51 @@ import { deleteActiveReport } from 'src/features/reports/ReportModal/actions';
import { useUnsavedChangesPrompt } from 'src/hooks/useUnsavedChangesPrompt';
import { getChartFormDiffs } from 'src/utils/getChartFormDiffs';
import { StreamingExportModal } from 'src/components/StreamingExportModal';
+import { ChartState, ExplorePageInitialData } from 'src/explore/types';
+import { Slice } from 'src/types/Chart';
+import { AlertObject } from 'src/features/alerts/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,
-};
-
-const saveButtonStyles = theme => css`
+interface ExploreActions {
+ updateChartTitle: (title: string) => void;
+ fetchFaveStar: (sliceId: string) => void;
+ saveFaveStar: (sliceId: string, isStarred: boolean) => void;
+ redirectSQLLab: (
+ formData: QueryFormData,
+ history?: ReturnType<typeof useHistory> | false,
+ ) => void;
+}
+
+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;
@@ -79,7 +94,7 @@ const additionalItemsStyles = theme => css`
}
`;
-export const ExploreChartHeader = ({
+export const ExploreChartHeader: FC<ExploreChartHeaderProps> = ({
dashboardId,
colorScheme: dashboardColorScheme,
slice,
@@ -100,7 +115,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 () => {
@@ -154,13 +170,13 @@ export const ExploreChartHeader = ({
};
const updateSlice = useCallback(
- slice => {
- dispatch(sliceUpdated(slice));
+ (updatedSlice: Slice) => {
+ dispatch(sliceUpdated(updatedSlice));
},
[dispatch],
);
- const handleReportDelete = async report => {
+ const handleReportDelete = async (report: AlertObject) => {
await dispatch(deleteActiveReport(report));
setCurrentReportDeleting(null);
};
@@ -169,8 +185,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],
);
@@ -360,6 +376,4 @@ export const ExploreChartHeader = ({
);
};
-ExploreChartHeader.propTypes = propTypes;
-
export default ExploreChartHeader;
diff --git a/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx b/superset-frontend/src/explore/components/ExploreViewContainer/index.tsx
similarity index 89%
rename from superset-frontend/src/explore/components/ExploreViewContainer/index.jsx
rename to superset-frontend/src/explore/components/ExploreViewContainer/index.tsx
index f8556a19ce..6c2e726e4e 100644
--- a/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx
+++ b/superset-frontend/src/explore/components/ExploreViewContainer/index.tsx
@@ -17,16 +17,18 @@
* under the License.
*/
/* eslint camelcase: 0 */
-import { memo, useCallback, useEffect, useMemo, useState } from 'react';
-import PropTypes from 'prop-types';
-import { bindActionCreators } from 'redux';
+import { memo, useCallback, useEffect, useMemo, useState, KeyboardEvent } from 'react';
+import { bindActionCreators, Dispatch } from 'redux';
import { connect } from 'react-redux';
import {
useChangeEffect,
useComponentDidMount,
usePrevious,
isMatrixifyEnabled,
+ QueryFormData,
+ JsonObject,
} from '@superset-ui/core';
+import { ControlStateMapping } 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 +55,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 +64,9 @@ 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, 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 +74,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;
@@ -234,16 +214,19 @@ 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): number {
+ const defaultKey = key === LocalStorageKeys.ControlsWidth ? 'controls_width' : 'datasource_width';
+ return getItem(key, defaultSidebarsWidth[defaultKey]);
}
-function setSidebarWidths(key, dimension) {
+function setSidebarWidths(key: LocalStorageKeys, dimension: { width: number }) {
const newDimension = Number(getSidebarWidths(key)) + dimension.width;
setItem(key, newDimension);
}
@@ -266,11 +249,96 @@ 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;
+}
+
+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;
+ };
}
-function ExploreViewContainer(props) {
+interface OwnProps {
+ addDangerToast: (msg: string) => void;
+ addSuccessToast?: (msg: string) => void;
+}
+
+interface StateProps {
+ isDatasourceMetaLoading: boolean;
+ datasource: Datasource;
+ datasource_type: string;
+ 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: ExploreRootState['explore'];
+ reports: JsonObject;
+ metadata?: ExplorePageInitialData['metadata'];
+ saveAction?: SaveActionType | null;
+ isSaveModalVisible: boolean;
+}
+
+interface DispatchProps {
+ actions: typeof exploreActions &
+ typeof datasourcesActions &
+ typeof saveModalActions &
+ typeof chartActions &
+ typeof logActions;
+}
+
+type ExploreViewContainerProps = StateProps & DispatchProps & OwnProps;
+
+function ExploreViewContainer(props: ExploreViewContainerProps) {
const dynamicPluginContext = usePluginContext();
const dynamicPlugin = dynamicPluginContext.dynamicPlugins[props.vizType];
const isDynamicPluginLoading = dynamicPlugin && dynamicPlugin.mounting;
@@ -886,14 +954,19 @@ function ExploreViewContainer(props) {
);
}
-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 &&
@@ -904,7 +977,7 @@ function patchBigNumberTotalFormData(form_data, slice) {
return form_data;
}
-function mapStateToProps(state) {
+function mapStateToProps(state: ExploreRootState) {
const {
explore,
charts,
@@ -1023,7 +1096,7 @@ function mapStateToProps(state) {
};
}
-function mapDispatchToProps(dispatch) {
+function mapDispatchToProps(dispatch: Dispatch) {
const actions = {
...exploreActions,
...datasourcesActions,
diff --git a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.jsx b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx
similarity index 88%
rename from superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.jsx
rename to superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx
index 05c9e19972..9c980eb3e7 100644
--- a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.jsx
+++ b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx
@@ -16,10 +16,23 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { useCallback, useMemo, useState } from 'react';
+import {
+ 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,
+} from '@superset-ui/core';
import { css, styled, useTheme, t } from '@apache-superset/core/ui';
import {
Icons,
@@ -28,7 +41,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 +58,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,
+ StreamingExportProgress,
+} 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 +138,62 @@ 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: StreamingExportProgress;
+ onCancel: () => void;
+ onRetry: () => void;
+ onDownload: () => void;
+}
+
+interface ExploreState {
+ charts?: Record<number, ChartState>;
+ explore?: JsonObject;
+ 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: 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 +203,10 @@ export const useExploreAdditionalActionsMenu = (
dashboardSearchTerm,
300,
);
- const chart = useSelector(
+ const chart = useSelector<ExploreState, ChartState | undefined>(
state => state.charts?.[getChartKey(state.explore)],
);
- const streamingThreshold = useSelector(
+ const streamingThreshold = useSelector<ExploreState, number>(
state =>
state.common?.conf?.CSV_STREAMING_ROW_THRESHOLD ||
DEFAULT_CSV_STREAMING_ROW_THRESHOLD,
@@ -337,9 +400,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 +428,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 +478,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 +491,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 +520,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.'),
diff --git a/superset-frontend/src/explore/exploreUtils/exploreUtils.test.jsx b/superset-frontend/src/explore/exploreUtils/exploreUtils.test.tsx
similarity index 95%
rename from superset-frontend/src/explore/exploreUtils/exploreUtils.test.jsx
rename to superset-frontend/src/explore/exploreUtils/exploreUtils.test.tsx
index c4dd565374..ecfcd7a662 100644
--- a/superset-frontend/src/explore/exploreUtils/exploreUtils.test.jsx
+++ b/superset-frontend/src/explore/exploreUtils/exploreUtils.test.tsx
@@ -28,15 +28,20 @@ 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 {
+ 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());
}
@@ -130,7 +135,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 +204,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();
});
});
@@ -293,9 +300,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);
});
});
diff --git a/superset-frontend/src/explore/exploreUtils/index.js b/superset-frontend/src/explore/exploreUtils/index.ts
similarity index 66%
rename from superset-frontend/src/explore/exploreUtils/index.js
rename to superset-frontend/src/explore/exploreUtils/index.ts
index 1ebde5a378..1e823f3fa4 100644
--- a/superset-frontend/src/explore/exploreUtils/index.js
+++ b/superset-frontend/src/explore/exploreUtils/index.ts
@@ -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,80 @@ 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 +128,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 +146,7 @@ export function getAnnotationJsonUrl(slice_id, force) {
.toString();
}
-export function getURIDirectory(endpointType = 'base') {
+export function getURIDirectory(endpointType: EndpointType | string = 'base'): string {
// Building the directory part of the URI
if (
['full', 'json', 'csv', 'query', 'results', 'samples'].includes(
@@ -90,10 +158,14 @@ export function getURIDirectory(endpointType = 'base') {
return ensureAppRoot('/explore/');
}
-export function mountExploreUrl(endpointType, extraSearch = {}, force = false) {
+export function mountExploreUrl(
+ endpointType: EndpointType | string,
+ extraSearch: Record<string, string | number> = {},
+ force = false,
+): string {
const uri = new URI('/');
const directory = getURIDirectory(endpointType);
- const search = uri.search(true);
+ const search = uri.search(true) as Record<string, string | number>;
Object.keys(extraSearch).forEach(key => {
search[key] = extraSearch[key];
});
@@ -106,7 +178,11 @@ export function mountExploreUrl(endpointType, extraSearch = {}, force = false) {
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).
@@ -135,7 +211,7 @@ export function getExploreUrl({
requestParams = {},
allowDomainSharding = false,
method = 'POST',
-}) {
+}: GetExploreUrlParams): string | null {
if (!formData.datasource) {
return null;
}
@@ -155,10 +231,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) {
@@ -191,7 +267,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];
}
});
@@ -199,7 +275,9 @@ export function getExploreUrl({
return uri.search(search).directory(directory).toString();
}
-export const getQuerySettings = formData => {
+export const getQuerySettings = (
+ formData: Partial<QueryFormData>,
+): [boolean, string] => {
const vizMetadata = getChartMetadataRegistry().get(formData.viz_type);
return [
vizMetadata?.useLegacyApi ?? false,
@@ -214,11 +292,11 @@ export const buildV1ChartDataPayload = async ({
resultType,
setDataMask,
ownState,
-}) => {
+}: BuildV1ChartDataPayloadParams): Promise<ReturnType<typeof buildQueryContext>> => {
const buildQuery =
getChartBuildQueryRegistry().get(formData.viz_type) ??
- (buildQueryformData =>
- buildQueryContext(buildQueryformData, baseQueryObject => [
+ ((buildQueryFormData: QueryFormData) =>
+ buildQueryContext(buildQueryFormData, baseQueryObject => [
{
...baseQueryObject,
},
@@ -229,7 +307,7 @@ export const buildV1ChartDataPayload = async ({
force,
result_format: resultFormat,
result_type: resultType,
- },
+ } as QueryFormData,
{
ownState,
hooks: {
@@ -239,8 +317,13 @@ export const buildV1ChartDataPayload = async ({
);
};
-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,
@@ -249,10 +332,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({
@@ -269,7 +352,6 @@ export const exportChart = async ({
resultFormat,
resultType,
ownState,
- parseMethod,
});
}
@@ -283,21 +365,28 @@ 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);
@@ -312,17 +401,21 @@ 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;
- const showComparator =
- DISABLE_INPUT_OPERATORS.map(
- op => OPERATOR_ENUM_TO_OPERATOR_TYPE[op].operation,
- ).indexOf(operator) === -1;
+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 = 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 ?? '');
@@ -348,6 +441,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()]);
}
diff --git a/superset-frontend/src/explore/reducers/exploreReducer.js b/superset-frontend/src/explore/reducers/exploreReducer.js
deleted file mode 100644
index df728ee482..0000000000
--- a/superset-frontend/src/explore/reducers/exploreReducer.js
+++ /dev/null
@@ -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?.validationDependancies) &&
- item.validationDependancies.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;
-}
diff --git a/superset-frontend/src/explore/reducers/exploreReducer.test.js b/superset-frontend/src/explore/reducers/exploreReducer.test.ts
similarity index 79%
rename from superset-frontend/src/explore/reducers/exploreReducer.test.js
rename to superset-frontend/src/explore/reducers/exploreReducer.test.ts
index bcc585630b..e31455e412 100644
--- a/superset-frontend/src/explore/reducers/exploreReducer.test.js
+++ b/superset-frontend/src/explore/reducers/exploreReducer.test.ts
@@ -17,12 +17,14 @@
* 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 newState = exploreReducer(initialState, action);
@@ -35,9 +37,10 @@ test('reset hiddenFormData on SET_STASH_FORM_DATA', () => {
});
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 newState = exploreReducer(initialState, restoreAction);
diff --git a/superset-frontend/src/explore/reducers/exploreReducer.ts b/superset-frontend/src/explore/reducers/exploreReducer.ts
new file mode 100644
index 0000000000..8052e3a660
--- /dev/null
+++ b/superset-frontend/src/explore/reducers/exploreReducer.ts
@@ -0,0 +1,597 @@
+/**
+ * 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, JsonObject } 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?: JsonObject;
+ 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;
+}
+
+interface SliceUpdatedAction {
+ type: typeof actions.SLICE_UPDATED;
+ slice: Slice & {
+ owners?: Array<{ value: number; label: string }>;
+ 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;
+
+interface ExtendedControlState extends ControlState {
+ valueKey?: string;
+ savedMetrics?: unknown[];
+ columns?: unknown[];
+ options?: unknown[];
+ default?: unknown;
+ rerender?: string[];
+ renderTrigger?: boolean;
+ validationErrors?: string[];
+ validationDependancies?: string[];
+}
+
+interface MetricItem {
+ label?: string;
+}
+
+type ActionHandlers = {
+ [key: string]: () => 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,
+ controlState.value,
+ );
+ if (
+ ensureIsArray(newFormData[controlName]).length > 0 &&
+ newFormData[controlName] !== controls[controlName].default
+ ) {
+ controlsTransferred.push(controlName);
+ }
+ }
+ });
+
+ const newState = {
+ ...state,
+ controls,
+ datasource: newDatasource,
+ };
+ return {
+ ...newState,
+ form_data: newFormData as QueryFormData,
+ controls: getControlsState(
+ newState,
+ 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, state, value),
+ } 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) => {
+ rerenderedControls[rerenderControlName] = {
+ ...getControlStateFromControlConfig(
+ newState.controls[rerenderControlName],
+ newState,
+ newState.controls[rerenderControlName].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?.validationDependancies) &&
+ item.validationDependancies.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,
+ overWrittenState,
+ controlState?.value,
+ ),
+ 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,
+ },
+ };
+ },
+ [actions.SET_EXPLORE_CONTROLS]() {
+ const typedAction = action as SetExploreControlsAction;
+ return {
+ ...state,
+ controls: getControlsState(
+ state,
+ 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,
+ 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;
+ return {
+ ...state,
+ slice: {
+ ...state.slice,
+ ...typedAction.slice,
+ owners: typedAction.slice.owners
+ ? typedAction.slice.owners.map(owner => owner.value)
+ : null,
+ } as Slice,
+ sliceName: typedAction.slice.slice_name ?? state.sliceName,
+ metadata: {
+ ...state.metadata,
+ owners: typedAction.slice.owners
+ ? typedAction.slice.owners.map(owner => owner.label)
+ : 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,
+ };
+ },
+ };
+ if (action.type in actionHandlers) {
+ return actionHandlers[action.type]();
+ }
+ return state;
+}
diff --git a/superset-frontend/src/features/reports/ReportModal/actions.js b/superset-frontend/src/features/reports/ReportModal/actions.js
deleted file mode 100644
index eea5bf6621..0000000000
--- a/superset-frontend/src/features/reports/ReportModal/actions.js
+++ /dev/null
@@ -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)));
- });
- };
-}
diff --git a/superset-frontend/src/features/reports/ReportModal/actions.ts b/superset-frontend/src/features/reports/ReportModal/actions.ts
new file mode 100644
index 0000000000..f45a6400d0
--- /dev/null
+++ b/superset-frontend/src/features/reports/ReportModal/actions.ts
@@ -0,0 +1,243 @@
+/**
+ * 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 { 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 attached to this dashboard.',
+ ),
+ ),
+ ),
+ );
+ };
+}
+
+const structureFetchAction = (
+ dispatch: Dispatch<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 {
+ 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')));
+ });
+
+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')));
+ });
+
+export function toggleActive(report: ReportObject, isActive: boolean) {
+ return function toggleActiveThunk(dispatch: Dispatch<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 active 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}`),
+ })
+ .catch(() => {
+ dispatch(addDangerToast(t('Your report could not be deleted')));
+ })
+ .finally(() => {
+ dispatch({ type: DELETE_REPORT, report } as DeleteReportAction);
+ dispatch(addSuccessToast(t('Deleted: %s', report.name)));
+ });
+ };
+}
+
+export type ReportAction =
+ | SetReportAction
+ | AddReportAction
+ | EditReportAction
+ | DeleteReportAction;
diff --git a/superset-frontend/src/features/reports/ReportModal/reducer.js b/superset-frontend/src/features/reports/ReportModal/reducer.js
deleted file mode 100644
index 0c53dbc600..0000000000
--- a/superset-frontend/src/features/reports/ReportModal/reducer.js
+++ /dev/null
@@ -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;
-}
diff --git a/superset-frontend/src/features/reports/ReportModal/reducer.ts b/superset-frontend/src/features/reports/ReportModal/reducer.ts
new file mode 100644
index 0000000000..14bd1c5e8d
--- /dev/null
+++ b/superset-frontend/src/features/reports/ReportModal/reducer.ts
@@ -0,0 +1,147 @@
+/**
+ * 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;
+ // For now report count should only be one, but we are checking in case
+ // functionality changes.
+ const reportObject = report.result?.find(
+ (r: ReportObject) =>
+ (r as Record<string, number>)[filterField] === 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 reportTypeId = report.dashboard ?? report.chart;
+ const creationMethod = report.creation_method as ReportCreationMethod;
+ // this is the id of either the chart or the dashboard associated with the report.
+
+ if (reportTypeId === undefined) {
+ return state;
+ }
+
+ return {
+ ...state,
+ [creationMethod]: {
+ ...state[creationMethod],
+ [reportTypeId]: report,
+ },
+ };
+ },
+
+ [EDIT_REPORT]() {
+ const actionTyped = action as EditReportAction;
+ const report: ReportObject = {
+ ...actionTyped.json.result,
+ id: actionTyped.json.id,
+ } as ReportObject;
+ const reportTypeId = report.dashboard ?? report.chart;
+ const creationMethod = report.creation_method as ReportCreationMethod;
+
+ if (reportTypeId === undefined) {
+ return state;
+ }
+
+ return {
+ ...state,
+ [creationMethod]: {
+ ...state[creationMethod],
+ [reportTypeId]: report,
+ },
+ };
+ },
+
+ [DELETE_REPORT]() {
+ const { report } = action as DeleteReportAction;
+ const reportTypeId = report.dashboard ?? report.chart;
+ const creationMethod = report.creation_method as ReportCreationMethod;
+
+ if (reportTypeId === undefined) {
+ return state;
+ }
+
+ const methodState = state[creationMethod];
+ return {
+ ...state,
+ [creationMethod]: methodState
+ ? omit(methodState, reportTypeId)
+ : undefined,
+ };
+ },
+ };
+
+ if (action.type in actionHandlers) {
+ return actionHandlers[action.type]();
+ }
+ return state;
+}
diff --git a/superset-frontend/src/middleware/logger.test.js b/superset-frontend/src/middleware/logger.test.ts
similarity index 84%
rename from superset-frontend/src/middleware/logger.test.js
rename to superset-frontend/src/middleware/logger.test.ts
index 035289c343..2c653e3b11 100644
--- a/superset-frontend/src/middleware/logger.test.js
+++ b/superset-frontend/src/middleware/logger.test.ts
@@ -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,12 +24,28 @@ import {
LOG_ACTIONS_LOAD_CHART,
LOG_ACTIONS_SPA_NAVIGATION,
} from 'src/logger/LogUtils';
+import { AnyAction } from 'redux';
+
+interface MockStore {
+ getState: () => {
+ dashboardInfo: { id: number };
+ impressionId: string;
+ };
+}
+
+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 mockStore = {
+ const next: SinonSpy = sinon.spy();
+ const mockStore: MockStore = {
getState: () => ({
dashboardInfo: {
id: dashboardId,
@@ -37,7 +53,7 @@ describe('logger middleware', () => {
impressionId: 'impression_id',
}),
};
- const action = {
+ const action: LogEventAction = {
type: LOG_EVENT,
payload: {
eventName: LOG_ACTIONS_LOAD_CHART,
@@ -52,7 +68,7 @@ describe('logger middleware', () => {
useFakeTimers: true,
});
- let postStub;
+ let postStub: SinonStub;
beforeEach(() => {
postStub = sinon.stub(SupersetClient, 'post');
});
@@ -63,7 +79,7 @@ describe('logger middleware', () => {
});
test('should listen to LOG_EVENT action type', () => {
- const action1 = {
+ const action1: AnyAction = {
type: 'ACTION_TYPE',
payload: {
some: 'data',
@@ -78,8 +94,8 @@ describe('logger middleware', () => {
expect(next.callCount).toBe(0);
timeSandbox.clock.tick(2000);
- expect(SupersetClient.post.callCount).toBe(1);
- expect(SupersetClient.post.getCall(0).args[0].endpoint).toMatch(
+ expect(postStub.callCount).toBe(1);
+ expect(postStub.getCall(0).args[0].endpoint).toMatch(
'/superset/log/',
);
});
@@ -96,8 +112,8 @@ describe('logger middleware', () => {
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;
+ 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({
@@ -120,9 +136,9 @@ describe('logger middleware', () => {
logger(mockStore)(next)(action);
timeSandbox.clock.tick(2000);
- expect(SupersetClient.post.callCount).toBe(1);
+ expect(postStub.callCount).toBe(1);
expect(
- SupersetClient.post.getCall(0).args[0].postPayload.events,
+ postStub.getCall(0).args[0].postPayload.events,
).toHaveLength(3);
});
diff --git a/superset-frontend/src/middleware/loggerMiddleware.js b/superset-frontend/src/middleware/loggerMiddleware.ts
similarity index 67%
rename from superset-frontend/src/middleware/loggerMiddleware.js
rename to superset-frontend/src/middleware/loggerMiddleware.ts
index d579c8d60a..55c476f9bc 100644
--- a/superset-frontend/src/middleware/loggerMiddleware.js
+++ b/superset-frontend/src/middleware/loggerMiddleware.ts
@@ -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;
diff --git a/superset-frontend/src/utils/common.test.jsx b/superset-frontend/src/utils/common.test.tsx
similarity index 88%
rename from superset-frontend/src/utils/common.test.jsx
rename to superset-frontend/src/utils/common.test.tsx
index a69153e6c1..d467be8172 100644
--- a/superset-frontend/src/utils/common.test.jsx
+++ b/superset-frontend/src/utils/common.test.tsx
@@ -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',
diff --git a/superset-frontend/src/utils/common.js b/superset-frontend/src/utils/common.ts
similarity index 68%
rename from superset-frontend/src/utils/common.js
rename to superset-frontend/src/utils/common.ts
index 4ceceb0b41..41a8fed11b 100644
--- a/superset-frontend/src/utils/common.js
+++ b/superset-frontend/src/utils/common.ts
@@ -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,7 +170,7 @@ export const detectOS = () => {
return 'Unknown OS';
};
-export const isSafari = () => {
+export const isSafari = (): boolean => {
const { userAgent } = navigator;
return userAgent && /^((?!chrome|android).)*safari/i.test(userAgent);
diff --git a/superset-frontend/src/visualizations/presets/MainPreset.js b/superset-frontend/src/visualizations/presets/MainPreset.ts
similarity index 100%
rename from superset-frontend/src/visualizations/presets/MainPreset.js
rename to superset-frontend/src/visualizations/presets/MainPreset.ts