mirror of
https://github.com/apache/superset.git
synced 2026-04-20 16:44:46 +00:00
536 lines
15 KiB
TypeScript
536 lines
15 KiB
TypeScript
/**
|
|
* 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 {
|
|
InfoTooltip,
|
|
Button,
|
|
AsyncSelect,
|
|
Form,
|
|
FormItem,
|
|
Modal,
|
|
Input,
|
|
Loading,
|
|
Divider,
|
|
} from '@superset-ui/core/components';
|
|
import {
|
|
DatasourceType,
|
|
isDefined,
|
|
logging,
|
|
SupersetClient,
|
|
t,
|
|
} from '@superset-ui/core';
|
|
import { css, styled, Alert } from '@apache-superset/core/ui';
|
|
import { Radio } from '@superset-ui/core/components/Radio';
|
|
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 { removeChartState } from 'src/dashboard/actions/dashboardState';
|
|
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<string, any>;
|
|
form_data?: Record<string, any>;
|
|
user: UserWithPermissionsAndRoles;
|
|
alert?: string;
|
|
sliceName?: string;
|
|
slice?: Record<string, any>;
|
|
datasource?: Record<string, any>;
|
|
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)`
|
|
.ant-modal-body {
|
|
overflow: visible;
|
|
}
|
|
i {
|
|
position: absolute;
|
|
top: -${({ theme }) => theme.sizeUnit * 5.25}px;
|
|
left: ${({ theme }) => theme.sizeUnit * 26.75}px;
|
|
}
|
|
`;
|
|
|
|
class SaveModal extends Component<SaveModalProps, SaveModalState> {
|
|
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<HTMLInputElement>) => {
|
|
// @ts-expect-error
|
|
this.setState({ datasetName: e.target.value });
|
|
};
|
|
|
|
onSliceNameChange(event: ChangeEvent<HTMLInputElement>) {
|
|
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);
|
|
|
|
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.dispatch(removeChartState(value.id));
|
|
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 (
|
|
<Form data-test="save-modal-body" layout="vertical">
|
|
<FormItem data-test="radio-group">
|
|
<Radio
|
|
id="overwrite-radio"
|
|
disabled={!this.canOverwriteSlice()}
|
|
checked={this.state.action === 'overwrite'}
|
|
onChange={() => this.changeAction('overwrite')}
|
|
data-test="save-overwrite-radio"
|
|
>
|
|
{t('Save (Overwrite)')}
|
|
</Radio>
|
|
<Radio
|
|
id="saveas-radio"
|
|
data-test="saveas-radio"
|
|
checked={this.state.action === 'saveas'}
|
|
onChange={() => this.changeAction('saveas')}
|
|
>
|
|
{t('Save as...')}
|
|
</Radio>
|
|
</FormItem>
|
|
<Divider />
|
|
<FormItem label={t('Chart name')} required>
|
|
<Input
|
|
name="new_slice_name"
|
|
type="text"
|
|
placeholder="Name"
|
|
value={this.state.newSliceName}
|
|
onChange={this.onSliceNameChange}
|
|
data-test="new-chart-name"
|
|
/>
|
|
</FormItem>
|
|
{this.props.datasource?.type === 'query' && (
|
|
<FormItem label={t('Dataset Name')} required>
|
|
<InfoTooltip
|
|
tooltip={t('A reusable dataset will be saved with your chart.')}
|
|
placement="right"
|
|
/>
|
|
<Input
|
|
name="dataset_name"
|
|
type="text"
|
|
placeholder="Dataset Name"
|
|
value={this.state.datasetName}
|
|
onChange={this.handleDatasetNameChange}
|
|
data-test="new-dataset-name"
|
|
/>
|
|
</FormItem>
|
|
)}
|
|
<FormItem
|
|
label={t('Add to dashboard')}
|
|
data-test="save-chart-modal-select-dashboard-form"
|
|
>
|
|
<AsyncSelect
|
|
allowClear
|
|
allowNewOptions
|
|
ariaLabel={t('Select a dashboard')}
|
|
options={this.loadDashboards}
|
|
onChange={this.onDashboardChange}
|
|
value={this.state.dashboard}
|
|
placeholder={
|
|
<div>
|
|
<b>{t('Select')}</b>
|
|
{t(' a dashboard OR ')}
|
|
<b>{t('create')}</b>
|
|
{t(' a new one')}
|
|
</div>
|
|
}
|
|
/>
|
|
</FormItem>
|
|
{info && <Alert type="info" message={info} closable={false} />}
|
|
{this.props.alert && (
|
|
<Alert
|
|
css={{ marginTop: info ? 16 : undefined }}
|
|
type="warning"
|
|
message={this.props.alert}
|
|
closable={false}
|
|
/>
|
|
)}
|
|
</Form>
|
|
);
|
|
};
|
|
|
|
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 = () => (
|
|
<div data-test="save-modal-footer">
|
|
<Button
|
|
id="btn_cancel"
|
|
buttonSize="small"
|
|
onClick={this.onHide}
|
|
buttonStyle="secondary"
|
|
>
|
|
{t('Cancel')}
|
|
</Button>
|
|
<Button
|
|
id="btn_modal_save_goto_dash"
|
|
buttonSize="small"
|
|
disabled={
|
|
!this.state.newSliceName ||
|
|
!this.state.dashboard ||
|
|
(this.props.datasource?.type !== DatasourceType.Table &&
|
|
!this.state.datasetName)
|
|
}
|
|
onClick={() => this.saveOrOverwrite(true)}
|
|
>
|
|
{t('Save & go to dashboard')}
|
|
</Button>
|
|
<Button
|
|
id="btn_modal_save"
|
|
buttonSize="small"
|
|
buttonStyle="primary"
|
|
onClick={() => this.saveOrOverwrite(false)}
|
|
disabled={
|
|
this.state.isLoading ||
|
|
!this.state.newSliceName ||
|
|
(this.props.datasource?.type !== DatasourceType.Table &&
|
|
!this.state.datasetName)
|
|
}
|
|
data-test="btn-modal-save"
|
|
>
|
|
{t('Save')}
|
|
</Button>
|
|
</div>
|
|
);
|
|
|
|
render() {
|
|
return (
|
|
<StyledModal
|
|
show={this.props.isVisible}
|
|
onHide={this.onHide}
|
|
title={t('Save chart')}
|
|
footer={this.renderFooter()}
|
|
>
|
|
{this.state.isLoading ? (
|
|
<div
|
|
css={css`
|
|
display: flex;
|
|
justify-content: center;
|
|
`}
|
|
>
|
|
<Loading position="normal" />
|
|
</div>
|
|
) : (
|
|
this.renderSaveChartModal()
|
|
)}
|
|
</StyledModal>
|
|
);
|
|
}
|
|
}
|
|
|
|
interface StateProps {
|
|
datasource: any;
|
|
slice: any;
|
|
user: UserWithPermissionsAndRoles;
|
|
dashboards: any;
|
|
alert: any;
|
|
isVisible: boolean;
|
|
}
|
|
|
|
function mapStateToProps({
|
|
explore,
|
|
saveModal,
|
|
user,
|
|
}: Record<string, any>): 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 };
|