/** * 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 { ChangeEvent, FormEvent, Component } from 'react'; import { Dispatch } from 'redux'; import rison from 'rison'; import { connect } from 'react-redux'; import { withRouter, RouteComponentProps } from 'react-router-dom'; import { InfoTooltipWithTrigger } from '@superset-ui/chart-controls'; import { css, DatasourceType, isDefined, logging, styled, SupersetClient, t, } from '@superset-ui/core'; import { Input } from 'src/components/Input'; import { Form, FormItem } from 'src/components/Form'; import Alert from 'src/components/Alert'; import Modal from 'src/components/Modal'; import { Radio } from 'src/components/Radio'; import Button from 'src/components/Button'; import { AsyncSelect } from 'src/components'; import Loading from 'src/components/Loading'; import { canUserEditDashboard } from 'src/dashboard/util/permissionUtils'; import { setSaveChartModalVisibility } from 'src/explore/actions/saveModalActions'; import { SaveActionType } from 'src/explore/types'; import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes'; import { Dashboard } from 'src/types/Dashboard'; // Session storage key for recent dashboard const SK_DASHBOARD_ID = 'save_chart_recent_dashboard'; interface SaveModalProps extends RouteComponentProps { addDangerToast: (msg: string) => void; actions: Record; form_data?: Record; user: UserWithPermissionsAndRoles; alert?: string; sliceName?: string; slice?: Record; datasource?: Record; dashboardId: '' | number | null; isVisible: boolean; dispatch: Dispatch; } type SaveModalState = { newSliceName?: string; datasetName: string; action: SaveActionType; isLoading: boolean; saveStatus?: string | null; dashboard?: { label: string; value: string | number }; }; export const StyledModal = styled(Modal)` .antd5-modal-body { overflow: visible; } i { position: absolute; top: -${({ theme }) => theme.gridUnit * 5.25}px; left: ${({ theme }) => theme.gridUnit * 26.75}px; } `; class SaveModal extends Component { constructor(props: SaveModalProps) { super(props); this.state = { newSliceName: props.sliceName, datasetName: props.datasource?.name, action: this.canOverwriteSlice() ? 'overwrite' : 'saveas', isLoading: false, dashboard: undefined, }; this.onDashboardChange = this.onDashboardChange.bind(this); this.onSliceNameChange = this.onSliceNameChange.bind(this); this.changeAction = this.changeAction.bind(this); this.saveOrOverwrite = this.saveOrOverwrite.bind(this); this.isNewDashboard = this.isNewDashboard.bind(this); this.onHide = this.onHide.bind(this); } isNewDashboard(): boolean { const { dashboard } = this.state; return typeof dashboard?.value === 'string'; } canOverwriteSlice(): boolean { return ( this.props.slice?.owners?.includes(this.props.user.userId) && !this.props.slice?.is_managed_externally ); } async componentDidMount() { let { dashboardId } = this.props; if (!dashboardId) { let lastDashboard = null; try { lastDashboard = sessionStorage.getItem(SK_DASHBOARD_ID); } catch (error) { // continue regardless of error } dashboardId = lastDashboard && parseInt(lastDashboard, 10); } if (dashboardId) { try { const result = (await this.loadDashboard(dashboardId)) as Dashboard; if (canUserEditDashboard(result, this.props.user)) { this.setState({ dashboard: { label: result.dashboard_title, value: result.id }, }); } } catch (error) { logging.warn(error); this.props.addDangerToast( t('An error occurred while loading dashboard information.'), ); } } } handleDatasetNameChange = (e: FormEvent) => { // @ts-expect-error this.setState({ datasetName: e.target.value }); }; onSliceNameChange(event: ChangeEvent) { this.setState({ newSliceName: event.target.value }); } onDashboardChange(dashboard: { label: string; value: string | number }) { this.setState({ dashboard }); } changeAction(action: SaveActionType) { this.setState({ action }); } onHide() { this.props.dispatch(setSaveChartModalVisibility(false)); } handleRedirect = (windowLocationSearch: string, chart: any) => { const searchParams = new URLSearchParams(windowLocationSearch); searchParams.set('save_action', this.state.action); if (this.state.action !== 'overwrite') { searchParams.delete('form_data_key'); } searchParams.set('slice_id', chart.id.toString()); return searchParams; }; async saveOrOverwrite(gotodash: boolean) { this.setState({ isLoading: true }); // Create or retrieve dashboard type DashboardGetResponse = { id: number; url: string; dashboard_title: string; }; try { if (this.props.datasource?.type === DatasourceType.Query) { const { schema, sql, database } = this.props.datasource; const { templateParams } = this.props.datasource; await this.props.actions.saveDataset({ schema, sql, database, templateParams, datasourceName: this.state.datasetName, }); } // Get chart dashboards let sliceDashboards: number[] = []; if (this.props.slice && this.state.action === 'overwrite') { sliceDashboards = await this.props.actions.getSliceDashboards( this.props.slice, ); } const formData = this.props.form_data || {}; delete formData.url_params; let dashboard: DashboardGetResponse | null = null; if (this.state.dashboard) { let validId = this.state.dashboard.value; if (this.isNewDashboard()) { const response = await this.props.actions.createDashboard( this.state.dashboard.label, ); validId = response.id; } try { dashboard = await this.loadDashboard(validId as number); } catch (error) { this.props.actions.saveSliceFailed(); return; } if (isDefined(dashboard) && isDefined(dashboard?.id)) { sliceDashboards = sliceDashboards.includes(dashboard.id) ? sliceDashboards : [...sliceDashboards, dashboard.id]; formData.dashboards = sliceDashboards; } } // Sets the form data this.props.actions.setFormData({ ...formData }); // Update or create slice let value: { id: number }; if (this.state.action === 'overwrite') { value = await this.props.actions.updateSlice( this.props.slice, this.state.newSliceName, sliceDashboards, dashboard ? { title: dashboard.dashboard_title, new: this.isNewDashboard(), } : null, ); } else { value = await this.props.actions.createSlice( this.state.newSliceName, sliceDashboards, dashboard ? { title: dashboard.dashboard_title, new: this.isNewDashboard(), } : null, ); } try { if (dashboard) { sessionStorage.setItem(SK_DASHBOARD_ID, `${dashboard.id}`); } else { sessionStorage.removeItem(SK_DASHBOARD_ID); } } catch (error) { // continue regardless of error } // Go to new dashboard url if (gotodash && dashboard) { this.props.history.push(dashboard.url); return; } const searchParams = this.handleRedirect(window.location.search, value); this.props.history.replace(`/explore/?${searchParams.toString()}`); this.setState({ isLoading: false }); this.onHide(); } finally { this.setState({ isLoading: false }); } } loadDashboard = async (id: number) => { const response = await SupersetClient.get({ endpoint: `/api/v1/dashboard/${id}`, }); return response.json.result; }; loadDashboards = async (search: string, page: number, pageSize: number) => { const queryParams = rison.encode({ columns: ['id', 'dashboard_title'], filters: [ { col: 'dashboard_title', opr: 'ct', value: search, }, { col: 'owners', opr: 'rel_m_m', value: this.props.user.userId, }, ], page, page_size: pageSize, order_column: 'dashboard_title', }); const { json } = await SupersetClient.get({ endpoint: `/api/v1/dashboard/?q=${queryParams}`, }); const { result, count } = json; return { data: result.map( (dashboard: { id: number; dashboard_title: string }) => ({ value: dashboard.id, label: dashboard.dashboard_title, }), ), totalCount: count, }; }; renderSaveChartModal = () => { const info = this.info(); return (
this.changeAction('overwrite')} data-test="save-overwrite-radio" > {t('Save (Overwrite)')} this.changeAction('saveas')} > {t('Save as...')}
{this.props.datasource?.type === 'query' && ( )} {t('Select')} {t(' a dashboard OR ')} {t('create')} {t(' a new one')} } /> {info && } {this.props.alert && ( )} ); }; info = () => { const isNewDashboard = this.isNewDashboard(); let chartWillBeCreated = false; if ( this.props.slice && (this.state.action !== 'overwrite' || !this.canOverwriteSlice()) ) { chartWillBeCreated = true; } if (chartWillBeCreated && isNewDashboard) { return t('A new chart and dashboard will be created.'); } if (chartWillBeCreated) { return t('A new chart will be created.'); } if (isNewDashboard) { return t('A new dashboard will be created.'); } return null; }; renderFooter = () => (
); render() { return ( {this.state.isLoading ? (
) : ( this.renderSaveChartModal() )}
); } } interface StateProps { datasource: any; slice: any; user: UserWithPermissionsAndRoles; dashboards: any; alert: any; isVisible: boolean; } function mapStateToProps({ explore, saveModal, user, }: Record): StateProps { return { datasource: explore.datasource, slice: explore.slice, user, dashboards: saveModal.dashboards, alert: saveModal.saveModalAlert, isVisible: saveModal.isVisible, }; } export default withRouter(connect(mapStateToProps)(SaveModal)); // User for testing purposes need to revisit once we convert this to functional component export { SaveModal as PureSaveModal };