diff --git a/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx b/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx index 3f0e84cf7c..a2c0e1d6ee 100644 --- a/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx +++ b/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx @@ -56,6 +56,7 @@ import { getFormDataFromControls } from 'src/explore/controlUtils'; 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 ExploreChartPanel from '../ExploreChartPanel'; import ConnectedControlPanelsContainer from '../ControlPanelsContainer'; import SaveModal from '../SaveModal'; @@ -589,6 +590,7 @@ function ExploreViewContainer(props) { /> {showingModal && ( ({}), @@ -79,16 +79,36 @@ const mockDashboardData = { result: [{ id: 'id', dashboard_title: 'dashboard title' }], }; +const queryStore = mockStore({ + chart: {}, + saveModal: { + dashboards: [], + }, + explore: { + datasource: { name: 'test', type: 'query' }, + slice: null, + alert: null, + }, + user: { + userId: 1, + }, +}); + +const queryDefaultProps = { + ...defaultProps, + form_data: { datasource: '107__query', url_params: { foo: 'bar' } }, +}; + const fetchDashboardsEndpoint = `glob:*/dashboardasync/api/read?_flt_0_owners=${1}`; beforeAll(() => fetchMock.get(fetchDashboardsEndpoint, mockDashboardData)); afterAll(() => fetchMock.restore()); -const getWrapper = () => +const getWrapper = (props = defaultProps, store = initialStore) => shallow( - + , ) .dive() @@ -168,7 +188,7 @@ test('sets action when overwriting slice', () => { test('fetches dashboards on component mount', () => { sinon.spy(defaultProps.actions, 'fetchDashboards'); mount( - + , ); @@ -198,3 +218,9 @@ test('removes alert', () => { expect(wrapper.state().alert).toBeNull(); defaultProps.actions.removeSaveModalAlert.restore(); }); + +test('set dataset name when chart source is query', () => { + const wrapper = getWrapper(queryDefaultProps, queryStore); + expect(wrapper.find('[data-test="new-dataset-name"]')).toExist(); + expect(wrapper.state().datasetName).toBe('test'); +}); diff --git a/superset-frontend/src/explore/components/SaveModal.tsx b/superset-frontend/src/explore/components/SaveModal.tsx index 8c0dd2a150..0297d6c6f8 100644 --- a/superset-frontend/src/explore/components/SaveModal.tsx +++ b/superset-frontend/src/explore/components/SaveModal.tsx @@ -21,7 +21,7 @@ import React from 'react'; import { Input } from 'src/components/Input'; import { Form, FormItem } from 'src/components/Form'; import Alert from 'src/components/Alert'; -import { t, styled } from '@superset-ui/core'; +import { t, styled, SupersetClient, DatasourceType } from '@superset-ui/core'; import ReactMarkdown from 'react-markdown'; import Modal from 'src/components/Modal'; import { Radio } from 'src/components/Radio'; @@ -30,12 +30,16 @@ import { Select } from 'src/components'; import { SelectValue } from 'antd/lib/select'; import { connect } from 'react-redux'; import { withRouter, RouteComponentProps } from 'react-router-dom'; +import { InfoTooltipWithTrigger } from '@superset-ui/chart-controls'; +import { getClientErrorObject } from 'src/utils/getClientErrorObject'; +import Loading from 'src/components/Loading'; // Session storage key for recent dashboard const SK_DASHBOARD_ID = 'save_chart_recent_dashboard'; const SELECT_PLACEHOLDER = t('**Select** a dashboard OR **create** a new one'); interface SaveModalProps extends RouteComponentProps { + addDangerToast: (msg: string) => void; onHide: () => void; actions: Record; form_data?: Record; @@ -55,14 +59,22 @@ type SaveModalState = { saveToDashboardId: number | string | null; newSliceName?: string; newDashboardName?: string; + datasetName: string; alert: string | null; action: ActionType; + isLoading: boolean; + saveStatus?: string | null; }; export const StyledModal = styled(Modal)` .ant-modal-body { overflow: visible; } + i { + position: absolute; + top: -${({ theme }) => theme.gridUnit * 5.25}px; + left: ${({ theme }) => theme.gridUnit * 26.75}px; + } `; class SaveModal extends React.Component { @@ -71,8 +83,11 @@ class SaveModal extends React.Component { this.state = { saveToDashboardId: null, newSliceName: props.sliceName, + datasetName: props.datasource?.name, alert: null, action: this.canOverwriteSlice() ? 'overwrite' : 'saveas', + isLoading: false, + saveStatus: null, }; this.onDashboardSelectChange = this.onDashboardSelectChange.bind(this); this.onSliceNameChange = this.onSliceNameChange.bind(this); @@ -115,6 +130,11 @@ class SaveModal extends React.Component { }); } + handleDatasetNameChange = (e: React.FormEvent) => { + // @ts-expect-error + this.setState({ datasetName: e.target.value }); + }; + onSliceNameChange(event: React.ChangeEvent) { this.setState({ newSliceName: event.target.value }); } @@ -130,8 +150,8 @@ class SaveModal extends React.Component { this.setState({ action }); } - saveOrOverwrite(gotodash: boolean) { - this.setState({ alert: null }); + async saveOrOverwrite(gotodash: boolean) { + this.setState({ alert: null, isLoading: true }); this.props.actions.removeSaveModalAlert(); // eslint-disable-next-line @typescript-eslint/no-unused-vars const { url_params, ...formData } = this.props.form_data || {}; @@ -145,6 +165,43 @@ class SaveModal extends React.Component { dashboard_title: string; }; + if (this.props.datasource?.type === DatasourceType.Query) { + const { schema, sql, database } = this.props.datasource; + const { templateParams } = this.props.datasource; + const columns = this.props.datasource?.columns || []; + + // Create a dataset object + await SupersetClient.post({ + endpoint: '/superset/sqllab_viz/', + postPayload: { + data: { + schema, + sql, + dbId: database?.id, + templateParams, + datasourceName: this.state.datasetName, + metrics: [], + columns, + }, + }, + }) + .then(({ json }) => json) + .then(async (data: { table_id: number }) => { + // Update form_data to point to new dataset + formData.datasource = `${data.table_id}__table`; + this.setState({ saveStatus: 'succeed' }); + }) + .catch(response => + getClientErrorObject(response).then(e => { + this.setState({ isLoading: false, saveStatus: 'failed' }); + this.props.addDangerToast(e.error); + }), + ); + } + + // Don't continue since server was unable to create dataset + if (this.state.saveStatus === 'failed') return; + let dashboard: DashboardGetResponse | null = null; if (this.state.newDashboardName || this.state.saveToDashboardId) { let saveToDashboardId = this.state.saveToDashboardId || null; @@ -221,9 +278,139 @@ class SaveModal extends React.Component { this.props.history.replace(`/explore/?${searchParams.toString()}`); }) as (value: any) => void); + this.setState({ isLoading: false }); this.props.onHide(); } + renderSaveChartModal = () => { + const dashboardSelectValue = + this.state.saveToDashboardId || this.state.newDashboardName; + + return ( +
+ {(this.state.alert || this.props.alert) && ( + + {this.state.alert ? this.state.alert : this.props.alert} + + + } + /> + )} + + this.changeAction('overwrite')} + data-test="save-overwrite-radio" + > + {t('Save (Overwrite)')} + + this.changeAction('saveas')} + > + {t('Save as...')} + + +
+ + + + {this.props.datasource?.type === 'query' && ( + + + + + )} + + - - -