mirror of https://github.com/apache/superset.git
Add 'Save As' feature for dashboards (#1669)
* Add 'Save As' feature for dashboards * Address code review comments
This commit is contained in:
parent
e3a9b393c2
commit
84e8f741ae
|
@ -7,6 +7,7 @@ const propTypes = {
|
|||
triggerNode: PropTypes.node.isRequired,
|
||||
modalTitle: PropTypes.node.isRequired,
|
||||
modalBody: PropTypes.node, // not required because it can be generated by beforeOpen
|
||||
modalFooter: PropTypes.node,
|
||||
beforeOpen: PropTypes.func,
|
||||
onExit: PropTypes.func,
|
||||
isButton: PropTypes.bool,
|
||||
|
@ -57,6 +58,11 @@ export default class ModalTrigger extends React.Component {
|
|||
<Modal.Body>
|
||||
{this.props.modalBody}
|
||||
</Modal.Body>
|
||||
{this.props.modalFooter &&
|
||||
<Modal.Footer>
|
||||
{this.props.modalFooter}
|
||||
</Modal.Footer>
|
||||
}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
const $ = window.$ = require('jquery');
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { ButtonGroup } from 'react-bootstrap';
|
||||
import Button from '../../components/Button';
|
||||
import { showModal } from '../../modules/utils';
|
||||
import CssEditor from './CssEditor';
|
||||
import RefreshIntervalModal from './RefreshIntervalModal';
|
||||
import SaveModal from './SaveModal';
|
||||
import CodeModal from './CodeModal';
|
||||
import SliceAdder from './SliceAdder';
|
||||
|
||||
|
@ -36,44 +37,6 @@ class Controls extends React.PureComponent {
|
|||
this.setState({ cssTemplates });
|
||||
});
|
||||
}
|
||||
save() {
|
||||
const dashboard = this.props.dashboard;
|
||||
const expandedSlices = {};
|
||||
$.each($('.slice_info'), function () {
|
||||
const widget = $(this).parents('.widget');
|
||||
const sliceDescription = widget.find('.slice_description');
|
||||
if (sliceDescription.is(':visible')) {
|
||||
expandedSlices[$(widget).attr('data-slice-id')] = true;
|
||||
}
|
||||
});
|
||||
const positions = dashboard.reactGridLayout.serialize();
|
||||
const data = {
|
||||
positions,
|
||||
css: this.state.css,
|
||||
expanded_slices: expandedSlices,
|
||||
};
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: '/superset/save_dash/' + dashboard.id + '/',
|
||||
data: {
|
||||
data: JSON.stringify(data),
|
||||
},
|
||||
success() {
|
||||
dashboard.onSave();
|
||||
showModal({
|
||||
title: 'Success',
|
||||
body: 'This dashboard was saved successfully.',
|
||||
});
|
||||
},
|
||||
error(error) {
|
||||
const errorMsg = this.getAjaxErrorMsg(error);
|
||||
showModal({
|
||||
title: 'Error',
|
||||
body: 'Sorry, there was an error saving this dashboard: </ br>' + errorMsg,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
changeCss(css) {
|
||||
this.setState({ css });
|
||||
this.props.dashboard.onChange();
|
||||
|
@ -123,13 +86,13 @@ class Controls extends React.PureComponent {
|
|||
>
|
||||
<i className="fa fa-edit" />
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!canSave}
|
||||
tooltip="Save the current positioning and CSS"
|
||||
onClick={this.save.bind(this)}
|
||||
>
|
||||
<i className="fa fa-save" />
|
||||
</Button>
|
||||
<SaveModal
|
||||
dashboard={dashboard}
|
||||
css={this.state.css}
|
||||
triggerNode={
|
||||
<i className="fa fa-save" />
|
||||
}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,152 @@
|
|||
const $ = window.$ = require('jquery');
|
||||
|
||||
import React from 'react';
|
||||
import { Button, FormControl, FormGroup, Radio } from 'react-bootstrap';
|
||||
import { getAjaxErrorMsg, showModal } from '../../modules/utils';
|
||||
|
||||
import ModalTrigger from '../../components/ModalTrigger';
|
||||
|
||||
const propTypes = {
|
||||
css: React.PropTypes.string,
|
||||
dashboard: React.PropTypes.object.isRequired,
|
||||
triggerNode: React.PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
class SaveModal extends React.PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
dashboard: props.dashboard,
|
||||
css: props.css,
|
||||
saveType: 'overwrite',
|
||||
newDashName: '',
|
||||
};
|
||||
this.modal = null;
|
||||
this.handleSaveTypeChange = this.handleSaveTypeChange.bind(this);
|
||||
this.handleNameChange = this.handleNameChange.bind(this);
|
||||
this.saveDashboard = this.saveDashboard.bind(this);
|
||||
}
|
||||
handleSaveTypeChange(event) {
|
||||
this.setState({
|
||||
saveType: event.target.value,
|
||||
});
|
||||
}
|
||||
handleNameChange(event) {
|
||||
this.setState({
|
||||
newDashName: event.target.value,
|
||||
saveType: 'newDashboard',
|
||||
});
|
||||
}
|
||||
saveDashboardRequest(data, url, saveType) {
|
||||
const dashboard = this.props.dashboard;
|
||||
const saveModal = this.modal;
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url,
|
||||
data: {
|
||||
data: JSON.stringify(data),
|
||||
},
|
||||
success(resp) {
|
||||
saveModal.close();
|
||||
dashboard.onSave();
|
||||
if (saveType === 'newDashboard') {
|
||||
window.location = '/superset/dashboard/' + resp.id + '/';
|
||||
} else {
|
||||
showModal({
|
||||
title: 'Success',
|
||||
body: 'This dashboard was saved successfully.',
|
||||
});
|
||||
}
|
||||
},
|
||||
error(error) {
|
||||
saveModal.close();
|
||||
const errorMsg = getAjaxErrorMsg(error);
|
||||
showModal({
|
||||
title: 'Error',
|
||||
body: 'Sorry, there was an error saving this dashboard: </ br>' + errorMsg,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
saveDashboard(saveType, newDashboardTitle) {
|
||||
const dashboard = this.props.dashboard;
|
||||
const expandedSlices = {};
|
||||
$.each($('.slice_info'), function () {
|
||||
const widget = $(this).parents('.widget');
|
||||
const sliceDescription = widget.find('.slice_description');
|
||||
if (sliceDescription.is(':visible')) {
|
||||
expandedSlices[$(widget).attr('data-slice-id')] = true;
|
||||
}
|
||||
});
|
||||
const positions = dashboard.reactGridLayout.serialize();
|
||||
const data = {
|
||||
positions,
|
||||
css: this.state.css,
|
||||
expanded_slices: expandedSlices,
|
||||
};
|
||||
let url = null;
|
||||
if (saveType === 'overwrite') {
|
||||
url = '/superset/save_dash/' + dashboard.id + '/';
|
||||
this.saveDashboardRequest(data, url, saveType);
|
||||
} else if (saveType === 'newDashboard') {
|
||||
if (!newDashboardTitle) {
|
||||
this.modal.close();
|
||||
showModal({
|
||||
title: 'Error',
|
||||
body: 'You must pick a name for the new dashboard',
|
||||
});
|
||||
} else {
|
||||
data.dashboard_title = newDashboardTitle;
|
||||
url = '/superset/copy_dash/' + dashboard.id + '/';
|
||||
this.saveDashboardRequest(data, url, saveType);
|
||||
}
|
||||
}
|
||||
}
|
||||
render() {
|
||||
return (
|
||||
<ModalTrigger
|
||||
ref={(modal) => { this.modal = modal; }}
|
||||
triggerNode={this.props.triggerNode}
|
||||
isButton
|
||||
modalTitle="Save Dashboard"
|
||||
modalBody={
|
||||
<FormGroup>
|
||||
<Radio
|
||||
value="overwrite"
|
||||
onChange={this.handleSaveTypeChange}
|
||||
checked={this.state.saveType === 'overwrite'}
|
||||
>
|
||||
Overwrite Dashboard [{this.props.dashboard.dashboard_title}]
|
||||
</Radio>
|
||||
<Radio
|
||||
value="newDashboard"
|
||||
onChange={this.handleSaveTypeChange}
|
||||
checked={this.state.saveType === 'newDashboard'}
|
||||
>
|
||||
Save as:
|
||||
</Radio>
|
||||
<FormControl
|
||||
type="text"
|
||||
placeholder="[dashboard name]"
|
||||
onFocus={this.handleNameChange}
|
||||
onChange={this.handleNameChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
}
|
||||
modalFooter={
|
||||
<div>
|
||||
<Button
|
||||
bsStyle="primary"
|
||||
onClick={() => { this.saveDashboard(this.state.saveType, this.state.newDashName); }}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
SaveModal.propTypes = propTypes;
|
||||
|
||||
export default SaveModal;
|
|
@ -185,3 +185,9 @@ export function getParamObject(form_data, datasource_type) {
|
|||
Object.assign(data, filterParams);
|
||||
return data;
|
||||
}
|
||||
|
||||
export function getAjaxErrorMsg(error) {
|
||||
const respJSON = error.responseJSON;
|
||||
return (respJSON && respJSON.message) ? respJSON.message :
|
||||
error.responseText;
|
||||
}
|
||||
|
|
|
@ -1637,33 +1637,64 @@ class Superset(BaseSupersetView):
|
|||
return Response(
|
||||
json.dumps(payload), mimetype="application/json")
|
||||
|
||||
@api
|
||||
@has_access_api
|
||||
@expose("/copy_dash/<dashboard_id>/", methods=['GET', 'POST'])
|
||||
def copy_dash(self, dashboard_id):
|
||||
"""Copy dashboard"""
|
||||
session = db.session()
|
||||
data = json.loads(request.form.get('data'))
|
||||
dash = models.Dashboard()
|
||||
original_dash = (session
|
||||
.query(models.Dashboard)
|
||||
.filter_by(id=dashboard_id).first())
|
||||
|
||||
dash.owners = [g.user] if g.user else []
|
||||
dash.dashboard_title = data['dashboard_title']
|
||||
dash.slices = original_dash.slices
|
||||
dash.params = original_dash.params
|
||||
|
||||
self._set_dash_metadata(dash, data)
|
||||
session.add(dash)
|
||||
session.commit()
|
||||
dash_json = dash.json_data
|
||||
session.close()
|
||||
return Response(
|
||||
dash_json, mimetype="application/json")
|
||||
|
||||
@api
|
||||
@has_access_api
|
||||
@expose("/save_dash/<dashboard_id>/", methods=['GET', 'POST'])
|
||||
def save_dash(self, dashboard_id):
|
||||
"""Save a dashboard's metadata"""
|
||||
session = db.session()
|
||||
dash = (session
|
||||
.query(models.Dashboard)
|
||||
.filter_by(id=dashboard_id).first())
|
||||
check_ownership(dash, raise_if_false=True)
|
||||
data = json.loads(request.form.get('data'))
|
||||
self._set_dash_metadata(dash, data)
|
||||
session.merge(dash)
|
||||
session.commit()
|
||||
session.close()
|
||||
return "SUCCESS"
|
||||
|
||||
@staticmethod
|
||||
def _set_dash_metadata(dashboard, data):
|
||||
positions = data['positions']
|
||||
slice_ids = [int(d['slice_id']) for d in positions]
|
||||
session = db.session()
|
||||
Dash = models.Dashboard # noqa
|
||||
dash = session.query(Dash).filter_by(id=dashboard_id).first()
|
||||
check_ownership(dash, raise_if_false=True)
|
||||
dash.slices = [o for o in dash.slices if o.id in slice_ids]
|
||||
dashboard.slices = [o for o in dashboard.slices if o.id in slice_ids]
|
||||
positions = sorted(data['positions'], key=lambda x: int(x['slice_id']))
|
||||
dash.position_json = json.dumps(positions, indent=4, sort_keys=True)
|
||||
md = dash.params_dict
|
||||
dashboard.position_json = json.dumps(positions, indent=4, sort_keys=True)
|
||||
md = dashboard.params_dict
|
||||
dashboard.css = data['css']
|
||||
|
||||
if 'filter_immune_slices' not in md:
|
||||
md['filter_immune_slices'] = []
|
||||
if 'filter_immune_slice_fields' not in md:
|
||||
md['filter_immune_slice_fields'] = {}
|
||||
md['expanded_slices'] = data['expanded_slices']
|
||||
dash.json_metadata = json.dumps(md, indent=4)
|
||||
dash.css = data['css']
|
||||
session.merge(dash)
|
||||
session.commit()
|
||||
session.close()
|
||||
return "SUCCESS"
|
||||
dashboard.json_metadata = json.dumps(md, indent=4)
|
||||
|
||||
@api
|
||||
@has_access_api
|
||||
|
|
|
@ -251,6 +251,42 @@ class CoreTests(SupersetTestCase):
|
|||
resp = self.client.post(url, data=dict(data=json.dumps(data)))
|
||||
assert "SUCCESS" in resp.data.decode('utf-8')
|
||||
|
||||
def test_copy_dash(self, username='admin'):
|
||||
self.login(username=username)
|
||||
dash = db.session.query(models.Dashboard).filter_by(
|
||||
slug="births").first()
|
||||
positions = []
|
||||
for i, slc in enumerate(dash.slices):
|
||||
d = {
|
||||
'col': 0,
|
||||
'row': i * 4,
|
||||
'size_x': 4,
|
||||
'size_y': 4,
|
||||
'slice_id': '{}'.format(slc.id)}
|
||||
positions.append(d)
|
||||
data = {
|
||||
'css': '',
|
||||
'expanded_slices': {},
|
||||
'positions': positions,
|
||||
'dashboard_title': 'Copy Of Births',
|
||||
}
|
||||
|
||||
# Save changes to Births dashboard and retrieve updated dash
|
||||
dash_id = dash.id
|
||||
url = '/superset/save_dash/{}/'.format(dash_id)
|
||||
self.client.post(url, data=dict(data=json.dumps(data)))
|
||||
dash = db.session.query(models.Dashboard).filter_by(
|
||||
id=dash_id).first()
|
||||
orig_json_data = json.loads(dash.json_data)
|
||||
|
||||
# Verify that copy matches original
|
||||
url = '/superset/copy_dash/{}/'.format(dash_id)
|
||||
resp = self.get_json_resp(url, data=dict(data=json.dumps(data)))
|
||||
self.assertEqual(resp['dashboard_title'], 'Copy Of Births')
|
||||
self.assertEqual(resp['position_json'], orig_json_data['position_json'])
|
||||
self.assertEqual(resp['metadata'], orig_json_data['metadata'])
|
||||
self.assertEqual(resp['slices'], orig_json_data['slices'])
|
||||
|
||||
def test_add_slices(self, username='admin'):
|
||||
self.login(username=username)
|
||||
dash = db.session.query(models.Dashboard).filter_by(
|
||||
|
|
Loading…
Reference in New Issue