Add 'Save As' feature for dashboards (#1669)

* Add 'Save As' feature for dashboards

* Address code review comments
This commit is contained in:
the-dcruz 2016-11-28 08:34:14 -08:00 committed by Maxime Beauchemin
parent e3a9b393c2
commit 84e8f741ae
6 changed files with 253 additions and 59 deletions

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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;

View File

@ -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;
}

View File

@ -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

View File

@ -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(