From 84e8f741ae969888c4f2501ada132f58bdcfb249 Mon Sep 17 00:00:00 2001 From: the-dcruz Date: Mon, 28 Nov 2016 08:34:14 -0800 Subject: [PATCH] Add 'Save As' feature for dashboards (#1669) * Add 'Save As' feature for dashboards * Address code review comments --- .../javascripts/components/ModalTrigger.jsx | 6 + .../dashboard/components/Controls.jsx | 55 ++----- .../dashboard/components/SaveModal.jsx | 152 ++++++++++++++++++ superset/assets/javascripts/modules/utils.js | 6 + superset/views.py | 57 +++++-- tests/core_tests.py | 36 +++++ 6 files changed, 253 insertions(+), 59 deletions(-) create mode 100644 superset/assets/javascripts/dashboard/components/SaveModal.jsx diff --git a/superset/assets/javascripts/components/ModalTrigger.jsx b/superset/assets/javascripts/components/ModalTrigger.jsx index b9ab32c3e6..58734b0a29 100644 --- a/superset/assets/javascripts/components/ModalTrigger.jsx +++ b/superset/assets/javascripts/components/ModalTrigger.jsx @@ -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 { {this.props.modalBody} + {this.props.modalFooter && + + {this.props.modalFooter} + + } ); } diff --git a/superset/assets/javascripts/dashboard/components/Controls.jsx b/superset/assets/javascripts/dashboard/components/Controls.jsx index d158587e46..503dba8747 100644 --- a/superset/assets/javascripts/dashboard/components/Controls.jsx +++ b/superset/assets/javascripts/dashboard/components/Controls.jsx @@ -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: ' + errorMsg, - }); - }, - }); - } changeCss(css) { this.setState({ css }); this.props.dashboard.onChange(); @@ -123,13 +86,13 @@ class Controls extends React.PureComponent { > - + + } + /> ); } diff --git a/superset/assets/javascripts/dashboard/components/SaveModal.jsx b/superset/assets/javascripts/dashboard/components/SaveModal.jsx new file mode 100644 index 0000000000..60e40365b5 --- /dev/null +++ b/superset/assets/javascripts/dashboard/components/SaveModal.jsx @@ -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: ' + 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 ( + { this.modal = modal; }} + triggerNode={this.props.triggerNode} + isButton + modalTitle="Save Dashboard" + modalBody={ + + + Overwrite Dashboard [{this.props.dashboard.dashboard_title}] + + + Save as: + + + + } + modalFooter={ +
+ +
+ } + /> + ); + } +} +SaveModal.propTypes = propTypes; + +export default SaveModal; diff --git a/superset/assets/javascripts/modules/utils.js b/superset/assets/javascripts/modules/utils.js index 98e4306647..065341d9ce 100644 --- a/superset/assets/javascripts/modules/utils.js +++ b/superset/assets/javascripts/modules/utils.js @@ -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; +} diff --git a/superset/views.py b/superset/views.py index 5614209bbc..70d4c0a72c 100755 --- a/superset/views.py +++ b/superset/views.py @@ -1637,33 +1637,64 @@ class Superset(BaseSupersetView): return Response( json.dumps(payload), mimetype="application/json") + @api + @has_access_api + @expose("/copy_dash//", 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//", 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 diff --git a/tests/core_tests.py b/tests/core_tests.py index d5de2cc31c..d79dd67baa 100644 --- a/tests/core_tests.py +++ b/tests/core_tests.py @@ -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(