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,
|
triggerNode: PropTypes.node.isRequired,
|
||||||
modalTitle: PropTypes.node.isRequired,
|
modalTitle: PropTypes.node.isRequired,
|
||||||
modalBody: PropTypes.node, // not required because it can be generated by beforeOpen
|
modalBody: PropTypes.node, // not required because it can be generated by beforeOpen
|
||||||
|
modalFooter: PropTypes.node,
|
||||||
beforeOpen: PropTypes.func,
|
beforeOpen: PropTypes.func,
|
||||||
onExit: PropTypes.func,
|
onExit: PropTypes.func,
|
||||||
isButton: PropTypes.bool,
|
isButton: PropTypes.bool,
|
||||||
|
@ -57,6 +58,11 @@ export default class ModalTrigger extends React.Component {
|
||||||
<Modal.Body>
|
<Modal.Body>
|
||||||
{this.props.modalBody}
|
{this.props.modalBody}
|
||||||
</Modal.Body>
|
</Modal.Body>
|
||||||
|
{this.props.modalFooter &&
|
||||||
|
<Modal.Footer>
|
||||||
|
{this.props.modalFooter}
|
||||||
|
</Modal.Footer>
|
||||||
|
}
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
const $ = window.$ = require('jquery');
|
const $ = window.$ = require('jquery');
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { ButtonGroup } from 'react-bootstrap';
|
import { ButtonGroup } from 'react-bootstrap';
|
||||||
import Button from '../../components/Button';
|
import Button from '../../components/Button';
|
||||||
import { showModal } from '../../modules/utils';
|
|
||||||
import CssEditor from './CssEditor';
|
import CssEditor from './CssEditor';
|
||||||
import RefreshIntervalModal from './RefreshIntervalModal';
|
import RefreshIntervalModal from './RefreshIntervalModal';
|
||||||
|
import SaveModal from './SaveModal';
|
||||||
import CodeModal from './CodeModal';
|
import CodeModal from './CodeModal';
|
||||||
import SliceAdder from './SliceAdder';
|
import SliceAdder from './SliceAdder';
|
||||||
|
|
||||||
|
@ -36,44 +37,6 @@ class Controls extends React.PureComponent {
|
||||||
this.setState({ cssTemplates });
|
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) {
|
changeCss(css) {
|
||||||
this.setState({ css });
|
this.setState({ css });
|
||||||
this.props.dashboard.onChange();
|
this.props.dashboard.onChange();
|
||||||
|
@ -123,13 +86,13 @@ class Controls extends React.PureComponent {
|
||||||
>
|
>
|
||||||
<i className="fa fa-edit" />
|
<i className="fa fa-edit" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<SaveModal
|
||||||
disabled={!canSave}
|
dashboard={dashboard}
|
||||||
tooltip="Save the current positioning and CSS"
|
css={this.state.css}
|
||||||
onClick={this.save.bind(this)}
|
triggerNode={
|
||||||
>
|
|
||||||
<i className="fa fa-save" />
|
<i className="fa fa-save" />
|
||||||
</Button>
|
}
|
||||||
|
/>
|
||||||
</ButtonGroup>
|
</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);
|
Object.assign(data, filterParams);
|
||||||
return data;
|
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(
|
return Response(
|
||||||
json.dumps(payload), mimetype="application/json")
|
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
|
@api
|
||||||
@has_access_api
|
@has_access_api
|
||||||
@expose("/save_dash/<dashboard_id>/", methods=['GET', 'POST'])
|
@expose("/save_dash/<dashboard_id>/", methods=['GET', 'POST'])
|
||||||
def save_dash(self, dashboard_id):
|
def save_dash(self, dashboard_id):
|
||||||
"""Save a dashboard's metadata"""
|
"""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'))
|
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']
|
positions = data['positions']
|
||||||
slice_ids = [int(d['slice_id']) for d in positions]
|
slice_ids = [int(d['slice_id']) for d in positions]
|
||||||
session = db.session()
|
dashboard.slices = [o for o in dashboard.slices if o.id in slice_ids]
|
||||||
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]
|
|
||||||
positions = sorted(data['positions'], key=lambda x: int(x['slice_id']))
|
positions = sorted(data['positions'], key=lambda x: int(x['slice_id']))
|
||||||
dash.position_json = json.dumps(positions, indent=4, sort_keys=True)
|
dashboard.position_json = json.dumps(positions, indent=4, sort_keys=True)
|
||||||
md = dash.params_dict
|
md = dashboard.params_dict
|
||||||
|
dashboard.css = data['css']
|
||||||
|
|
||||||
if 'filter_immune_slices' not in md:
|
if 'filter_immune_slices' not in md:
|
||||||
md['filter_immune_slices'] = []
|
md['filter_immune_slices'] = []
|
||||||
if 'filter_immune_slice_fields' not in md:
|
if 'filter_immune_slice_fields' not in md:
|
||||||
md['filter_immune_slice_fields'] = {}
|
md['filter_immune_slice_fields'] = {}
|
||||||
md['expanded_slices'] = data['expanded_slices']
|
md['expanded_slices'] = data['expanded_slices']
|
||||||
dash.json_metadata = json.dumps(md, indent=4)
|
dashboard.json_metadata = json.dumps(md, indent=4)
|
||||||
dash.css = data['css']
|
|
||||||
session.merge(dash)
|
|
||||||
session.commit()
|
|
||||||
session.close()
|
|
||||||
return "SUCCESS"
|
|
||||||
|
|
||||||
@api
|
@api
|
||||||
@has_access_api
|
@has_access_api
|
||||||
|
|
|
@ -251,6 +251,42 @@ class CoreTests(SupersetTestCase):
|
||||||
resp = self.client.post(url, data=dict(data=json.dumps(data)))
|
resp = self.client.post(url, data=dict(data=json.dumps(data)))
|
||||||
assert "SUCCESS" in resp.data.decode('utf-8')
|
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'):
|
def test_add_slices(self, username='admin'):
|
||||||
self.login(username=username)
|
self.login(username=username)
|
||||||
dash = db.session.query(models.Dashboard).filter_by(
|
dash = db.session.query(models.Dashboard).filter_by(
|
||||||
|
|
Loading…
Reference in New Issue