diff --git a/caravel/assets/javascripts/dashboard.jsx b/caravel/assets/javascripts/dashboard/Dashboard.jsx similarity index 51% rename from caravel/assets/javascripts/dashboard.jsx rename to caravel/assets/javascripts/dashboard/Dashboard.jsx index 6496a23b053f7..1d2cec105e8ac 100644 --- a/caravel/assets/javascripts/dashboard.jsx +++ b/caravel/assets/javascripts/dashboard/Dashboard.jsx @@ -1,196 +1,22 @@ var $ = window.$ = require('jquery'); var jQuery = window.jQuery = $; -var px = require('./modules/caravel.js'); +var px = require('../modules/caravel.js'); var d3 = require('d3'); -var showModal = require('./modules/utils.js').showModal; -require('bootstrap'); +var showModal = require('../modules/utils.js').showModal; + import React from 'react'; import { render } from 'react-dom'; +import SliceAdder from './components/SliceAdder.jsx'; +import GridLayout from './components/GridLayout.jsx'; var ace = require('brace'); +require('bootstrap'); require('brace/mode/css'); require('brace/theme/crimson_editor'); - -require('./caravel-select2.js'); -require('../node_modules/react-grid-layout/css/styles.css'); -require('../node_modules/react-resizable/css/styles.css'); - -require('../stylesheets/dashboard.css'); - -import { Responsive, WidthProvider } from "react-grid-layout"; -const ResponsiveReactGridLayout = WidthProvider(Responsive); - -class SliceCell extends React.Component { - render() { - const slice = this.props.slice, - createMarkup = function () { - return { __html: slice.description_markeddown }; - }; - - return ( -
-
-
-
- {slice.slice_name} -
-
-
- - - - - - -
-
- {slice.description ? - - - - : ""} - - - - - - - - - -
-
- -
-
-
-
-
- -
- loading -
-
-
-
- ); - } -} - -class GridLayout extends React.Component { - removeSlice(sliceId) { - $('[data-toggle="tooltip"]').tooltip("hide"); - this.setState({ - layout: this.state.layout.filter(function (reactPos) { - return reactPos.i !== String(sliceId); - }), - slices: this.state.slices.filter(function (slice) { - return slice.slice_id !== sliceId; - }), - sliceElements: this.state.sliceElements.filter(function (sliceElement) { - return sliceElement.key !== String(sliceId); - }) - }); - } - - onResizeStop(layout, oldItem, newItem) { - if (oldItem.w != newItem.w || oldItem.h != newItem.h) { - this.setState({ - layout: layout - }, function () { - this.props.dashboard.getSlice(newItem.i).resize(); - }); - } - } - - onDragStop(layout) { - this.setState({ - layout: layout - }); - } - - serialize() { - return this.state.layout.map(function (reactPos) { - return { - slice_id: reactPos.i, - col: reactPos.x + 1, - row: reactPos.y, - size_x: reactPos.w, - size_y: reactPos.h - }; - }); - } - - componentWillMount() { - var layout = [], - sliceElements = []; - - this.props.slices.forEach(function (slice, index) { - var pos = this.props.posDict[slice.slice_id]; - if (!pos) { - pos = { - col: (index * 4 + 1) % 12, - row: Math.floor((index) / 3) * 4, - size_x: 4, - size_y: 4 - }; - } - - sliceElements.push( -
- -
- ); - - layout.push({ - i: String(slice.slice_id), - x: pos.col - 1, - y: pos.row, - w: pos.size_x, - minW: 2, - h: pos.size_y - }); - }, this); - - this.setState({ - layout: layout, - sliceElements: sliceElements, - slices: this.props.slices - }); - } - - render() { - return ( - - {this.state.sliceElements} - - ); - } -} +require('../caravel-select2.js'); +require('../../stylesheets/dashboard.css'); var Dashboard = function (dashboardData) { - var reactGridLayout; - var dashboard = $.extend(dashboardData, { filters: {}, init: function () { @@ -202,10 +28,10 @@ var Dashboard = function (dashboardData) { dashboard.slices.forEach(function (data) { if (data.error) { var html = '
' + data.error + '
'; - $("#slice_" + data.slice_id).find('.token').html(html); + $('#slice_' + data.slice_id).find('.token').html(html); } else { var slice = px.Slice(data, dash); - $("#slice_" + data.slice_id).find('a.refresh').click(function () { + $('#slice_' + data.slice_id).find('a.refresh').click(function () { slice.render(true); }); sliceObjects.push(slice); @@ -315,17 +141,90 @@ var Dashboard = function (dashboardData) { } } }, + showAddSlice: function () { + var slicesOnDashMap = {}; + this.reactGridLayout.serialize().forEach(function (position) { + slicesOnDashMap[position.slice_id] = true; + }, this); + + render( + , + document.getElementById("add-slice-container") + ); + }, + getAjaxErrorMsg: function (error) { + var respJSON = error.responseJSON; + return (respJSON && respJSON.message) ? respJSON.message : + error.responseText; + }, + addSlicesToDashboard: function (sliceIds) { + $.ajax({ + type: "POST", + url: '/caravel/add_slices/' + dashboard.id + '/', + data: { + data: JSON.stringify({ slice_ids: sliceIds }) + }, + success: function () { + // Refresh page to allow for slices to re-render + window.location.reload(); + }, + error: function (error) { + var errorMsg = this.getAjaxErrorMsg(error); + showModal({ + title: "Error", + body: "Sorry, there was an error adding slices to this dashboard: " + errorMsg + }); + }.bind(this) + }); + }, + saveDashboard: function () { + var expandedSlices = {}; + $.each($(".slice_info"), function (i, d) { + var widget = $(this).parents('.widget'); + var sliceDescription = widget.find('.slice_description'); + if (sliceDescription.is(":visible")) { + expandedSlices[$(widget).attr('data-slice-id')] = true; + } + }); + var data = { + positions: this.reactGridLayout.serialize(), + css: this.editor.getValue(), + expanded_slices: expandedSlices + }; + $.ajax({ + type: "POST", + url: '/caravel/save_dash/' + dashboard.id + '/', + data: { + data: JSON.stringify(data) + }, + success: function () { + showModal({ + title: "Success", + body: "This dashboard was saved successfully." + }); + }, + error: function (error) { + var errorMsg = this.getAjaxErrorMsg(error); + showModal({ + title: "Error", + body: "Sorry, there was an error saving this dashboard: " + errorMsg + }); + } + }); + }, initDashboardView: function () { - var posDict = {} + this.posDict = {}; this.position_json.forEach(function (position) { - posDict[position.slice_id] = position; - }); + this.posDict[position.slice_id] = position; + }, this); - reactGridLayout = render( - , + this.reactGridLayout = render( + , document.getElementById("grid-container") ); + this.curUserId = $('.dashboard').data('user'); + dashboard = this; // Displaying widget controls on hover @@ -338,46 +237,11 @@ var Dashboard = function (dashboardData) { } ); $("div.grid-container").css('visibility', 'visible'); - $("#savedash").click(function () { - var expanded_slices = {}; - $.each($(".slice_info"), function (i, d) { - var widget = $(this).parents('.widget'); - var slice_description = widget.find('.slice_description'); - if (slice_description.is(":visible")) { - expanded_slices[$(widget).attr('data-slice-id')] = true; - } - }); - var data = { - positions: reactGridLayout.serialize(), - css: editor.getValue(), - expanded_slices: expanded_slices - }; - $.ajax({ - type: "POST", - url: '/caravel/save_dash/' + dashboard.id + '/', - data: { - data: JSON.stringify(data) - }, - success: function () { - showModal({ - title: "Success", - body: "This dashboard was saved successfully." - }); - }, - error: function (error) { - var respJSON = error.responseJSON; - var errorMsg = (respJSON && respJSON.message) ? respJSON.message : - error.responseText; - showModal({ - title: "Error", - body: "Sorry, there was an error saving this dashboard:
" + errorMsg - }); - console.warn("Save dashboard error", error); - } - }); - }); + $("#savedash").click(this.saveDashboard.bind(this)); + $("#add-slice").click(this.showAddSlice.bind(this)); var editor = ace.edit("dash_css"); + this.editor = editor; editor.$blockScrolling = Infinity; editor.setTheme("ace/theme/crimson_editor"); diff --git a/caravel/assets/javascripts/dashboard/components/GridLayout.jsx b/caravel/assets/javascripts/dashboard/components/GridLayout.jsx new file mode 100644 index 0000000000000..d3a798977676a --- /dev/null +++ b/caravel/assets/javascripts/dashboard/components/GridLayout.jsx @@ -0,0 +1,186 @@ +import React, { PropTypes } from 'react'; +import { Responsive, WidthProvider } from 'react-grid-layout'; +const ResponsiveReactGridLayout = WidthProvider(Responsive); + +require('../../../node_modules/react-grid-layout/css/styles.css'); +require('../../../node_modules/react-resizable/css/styles.css'); + +const sliceCellPropTypes = { + slice: PropTypes.object.isRequired, + removeSlice: PropTypes.func.isRequired, + expandedSlices: PropTypes.object +}; + +const gridLayoutPropTypes = { + dashboard: PropTypes.object.isRequired, + slices: PropTypes.arrayOf(PropTypes.object).isRequired, + posDict: PropTypes.object.isRequired +}; + +class SliceCell extends React.Component { + render() { + const slice = this.props.slice, + createMarkup = function () { + return { __html: slice.description_markeddown }; + }; + + return ( +
+
+
+
+ {slice.slice_name} +
+
+ +
+ {slice.description ? + + + + : ""} + + + + + + + + + +
+
+ +
+
+
+
+
+ +
+ loading +
+
+
+
+ ); + } +} + +class GridLayout extends React.Component { + removeSlice(sliceId) { + $('[data-toggle="tooltip"]').tooltip("hide"); + this.setState({ + layout: this.state.layout.filter(function (reactPos) { + return reactPos.i !== String(sliceId); + }), + slices: this.state.slices.filter(function (slice) { + return slice.slice_id !== sliceId; + }) + }); + } + + onResizeStop(layout, oldItem, newItem) { + if (oldItem.w !== newItem.w || oldItem.h !== newItem.h) { + this.setState({ + layout: layout + }, function () { + this.props.dashboard.getSlice(newItem.i).resize(); + }); + } + } + + onDragStop(layout) { + this.setState({ + layout: layout + }); + } + + serialize() { + return this.state.layout.map(function (reactPos) { + return { + slice_id: reactPos.i, + col: reactPos.x + 1, + row: reactPos.y, + size_x: reactPos.w, + size_y: reactPos.h + }; + }); + } + + componentWillMount() { + var layout = []; + + this.props.slices.forEach(function (slice, index) { + var pos = this.props.posDict[slice.slice_id]; + if (!pos) { + pos = { + col: (index * 4 + 1) % 12, + row: Math.floor((index) / 3) * 4, + size_x: 4, + size_y: 4 + }; + } + + layout.push({ + i: String(slice.slice_id), + x: pos.col - 1, + y: pos.row, + w: pos.size_x, + minW: 2, + h: pos.size_y + }); + }, this); + + this.setState({ + layout: layout, + slices: this.props.slices + }); + } + + render() { + return ( + + {this.state.slices.map((slice) => { + return ( +
+ +
+ ); + })} +
+ ); + } +} + +SliceCell.propTypes = sliceCellPropTypes; +GridLayout.propTypes = gridLayoutPropTypes; + +export default GridLayout; diff --git a/caravel/assets/javascripts/dashboard/components/Modal.jsx b/caravel/assets/javascripts/dashboard/components/Modal.jsx new file mode 100644 index 0000000000000..daf68ca556b30 --- /dev/null +++ b/caravel/assets/javascripts/dashboard/components/Modal.jsx @@ -0,0 +1,42 @@ +import React, { PropTypes } from 'react'; + +const propTypes = { + modalId: PropTypes.string.isRequired, + title: PropTypes.string, + modalContent: PropTypes.node, + customButtons: PropTypes.node +}; + +class Modal extends React.Component { + render() { + return ( + + ); + } +} + +Modal.propTypes = propTypes; + +export default Modal; diff --git a/caravel/assets/javascripts/dashboard/components/SliceAdder.jsx b/caravel/assets/javascripts/dashboard/components/SliceAdder.jsx new file mode 100644 index 0000000000000..5068c25a06469 --- /dev/null +++ b/caravel/assets/javascripts/dashboard/components/SliceAdder.jsx @@ -0,0 +1,190 @@ +import React, { PropTypes } from 'react'; +import update from 'immutability-helper'; +import { BootstrapTable, TableHeaderColumn } from 'react-bootstrap-table'; +import Modal from './Modal.jsx'; +require('../../../node_modules/react-bootstrap-table/css/react-bootstrap-table.css'); + +const propTypes = { + dashboard: PropTypes.object.isRequired, + caravel: PropTypes.object.isRequired +}; + +class SliceAdder extends React.Component { + constructor(props) { + super(props); + + this.state = { + slices: [] + }; + + this.addSlices = this.addSlices.bind(this); + this.toggleSlice = this.toggleSlice.bind(this); + this.toggleAllSlices = this.toggleAllSlices.bind(this); + this.slicesLoaded = false; + this.selectRowProp = { + mode: "checkbox", + clickToSelect: true, + onSelect: this.toggleSlice, + onSelectAll: this.toggleAllSlices + }; + this.options = { + defaultSortOrder: "desc", + defaultSortName: "modified", + sizePerPage: 10 + }; + } + + componentDidMount() { + var uri = "/sliceaddview/api/read?_flt_0_created_by=" + this.props.dashboard.curUserId; + this.slicesRequest = $.ajax({ + url: uri, + type: 'GET', + success: function (response) { + this.slicesLoaded = true; + + // Prepare slice data for table + let slices = response.result; + slices.forEach(function (slice) { + slice.id = slice.data.slice_id; + slice.sliceName = slice.data.slice_name; + slice.vizType = slice.viz_type; + slice.modified = slice.modified; + }); + + this.setState({ + slices: slices, + selectionMap: {} + }); + }.bind(this), + error: function (error) { + this.errored = true; + this.setState({ + errorMsg: this.props.dashboard.getAjaxErrorMsg(error) + }); + }.bind(this) + }); + } + + componentWillUnmount() { + this.slicesRequest.abort(); + } + + addSlices() { + var slices = this.state.slices.filter(function (slice) { + return this.state.selectionMap[slice.id]; + }, this); + + slices.forEach(function (slice) { + var sliceObj = this.props.caravel.Slice(slice.data, this.props.dashboard); + $("#slice_" + slice.data.slice_id).find('a.refresh').click(function () { + sliceObj.render(true); + }); + this.props.dashboard.slices.push(sliceObj); + }, this); + + this.props.dashboard.addSlicesToDashboard(Object.keys(this.state.selectionMap)); + } + + toggleSlice(slice) { + this.setState({ + selectionMap: update(this.state.selectionMap, { + [slice.id]: { + $set: !this.state.selectionMap[slice.id] + } + }) + }); + } + + toggleAllSlices(value) { + let updatePayload = {}; + + this.state.slices.forEach(function (slice) { + updatePayload[slice.id] = { + $set: value + }; + }, this); + + this.setState({ + selectionMap: update(this.state.selectionMap, updatePayload) + }); + } + + modifiedDateComparator(a, b, order) { + if (order === 'desc') { + if (a.changed_on > b.changed_on) { + return -1; + } else if (a.changed_on < b.changed_on) { + return 1; + } + return 0; + } + + if (a.changed_on < b.changed_on) { + return -1; + } else if (a.changed_on > b.changed_on) { + return 1; + } + return 0; + } + + render() { + const hideLoad = this.slicesLoaded || this.errored; + const enableAddSlice = this.state.selectionMap && Object.keys(this.state.selectionMap).some(function (key) { + return this.state.selectionMap[key]; + }, this); + const modalContent = ( +
+ {hideLoad +
+ {this.state.errorMsg} +
+
+ + Name + Viz + modified}> + Modified + + +
+
+ ); + const customButtons = [ + + ]; + + return ( + + ); + } +} + +SliceAdder.propTypes = propTypes; + +export default SliceAdder; diff --git a/caravel/assets/package.json b/caravel/assets/package.json index 83ca0994de2ea..1f79817671493 100644 --- a/caravel/assets/package.json +++ b/caravel/assets/package.json @@ -58,6 +58,7 @@ "exports-loader": "^0.6.3", "font-awesome": "^4.5.0", "gridster": "^0.5.6", + "immutability-helper": "^2.0.0", "imports-loader": "^0.6.5", "jquery": "^2.2.1", "jquery-ui": "^1.10.5", @@ -69,6 +70,7 @@ "nvd3": "1.8.3", "react": "^15.2.0", "react-bootstrap": "^0.28.3", + "react-bootstrap-table": "^2.3.7", "react-dom": "^0.14.8", "react-grid-layout": "^0.12.3", "react-map-gl": "^1.0.0-beta-10", diff --git a/caravel/assets/stylesheets/dashboard.css b/caravel/assets/stylesheets/dashboard.css index 3a77cf0c71c96..b2beb779f3b43 100644 --- a/caravel/assets/stylesheets/dashboard.css +++ b/caravel/assets/stylesheets/dashboard.css @@ -44,3 +44,18 @@ .dashboard div.nvtooltip { z-index: 888; /* this lets tool tips go on top of other slices */ } + +.modal img.loading { + width: 50px; + margin: 0; + position: relative; +} + +.react-bs-container-body { + max-height: 400px; + overflow-y: auto; +} + +.hidden, #pageDropDown { + display: none; +} diff --git a/caravel/assets/webpack.config.js b/caravel/assets/webpack.config.js index ef58a1b9cad39..fd71b302d89e1 100644 --- a/caravel/assets/webpack.config.js +++ b/caravel/assets/webpack.config.js @@ -6,7 +6,7 @@ var config = { // for now generate one compiled js file per entry point / html page entry: { 'css-theme': APP_DIR + '/javascripts/css-theme.js', - dashboard: APP_DIR + '/javascripts/dashboard.jsx', + dashboard: APP_DIR + '/javascripts/dashboard/Dashboard.jsx', explore: APP_DIR + '/javascripts/explore/explore.jsx', welcome: APP_DIR + '/javascripts/welcome.js', sql: APP_DIR + '/javascripts/sql.js', diff --git a/caravel/templates/caravel/dashboard.html b/caravel/templates/caravel/dashboard.html index 12f5d443f93af..afdf144114d8d 100644 --- a/caravel/templates/caravel/dashboard.html +++ b/caravel/templates/caravel/dashboard.html @@ -7,7 +7,7 @@ {% block title %}[dashboard] {{ dashboard.dashboard_title }}{% endblock %} {% block body %} -
+
{% include 'caravel/flash_wrapper.html' %} @@ -64,6 +64,7 @@
Choose how frequent should the dashboard refresh
+
@@ -75,28 +76,29 @@

- {% if dash_edit_perm %} -
- - - - - - - - -
- {% endif %} +
+ + + + + + + + + +
diff --git a/caravel/views.py b/caravel/views.py index 8004d27a5ee08..f77958dddb294 100755 --- a/caravel/views.py +++ b/caravel/views.py @@ -565,7 +565,6 @@ class SliceAsync(SliceModelView): # noqa 'creator', 'modified', 'icons'] label_columns = { 'icons': ' ', - 'viz_type': _('Type'), 'slice_link': _('Slice'), 'viz_type': _('Visualization Type'), } @@ -573,6 +572,19 @@ class SliceAsync(SliceModelView): # noqa appbuilder.add_view_no_menu(SliceAsync) +class SliceAddView(SliceModelView): # noqa + list_columns = [ + 'slice_link', 'viz_type', + 'owners', 'modified', 'data', 'changed_on'] + label_columns = { + 'icons': ' ', + 'slice_link': _('Slice'), + 'viz_type': _('Visualization Type'), + } + +appbuilder.add_view_no_menu(SliceAddView) + + class DashboardModelView(CaravelModelView, DeleteMixin): # noqa datamodel = SQLAInterface(models.Dashboard) list_columns = ['dashboard_link', 'creator', 'modified'] @@ -1020,6 +1032,23 @@ def save_dash(self, dashboard_id): session.close() return "SUCCESS" + @api + @has_access_api + @expose("/add_slices//", methods=['POST']) + def add_slices(self, dashboard_id): + """Add and save slices to a dashboard""" + data = json.loads(request.form.get('data')) + session = db.session() + Slice = models.Slice # noqa + dash = session.query(models.Dashboard).filter_by(id=dashboard_id).first() + check_ownership(dash, raise_if_false=True) + new_slices = session.query(Slice).filter(Slice.id.in_(data['slice_ids'])) + dash.slices += new_slices + session.merge(dash) + session.commit() + session.close() + return "SLICES ADDED" + @api @has_access_api @expose("/testconn", methods=["POST", "GET"]) @@ -1100,6 +1129,7 @@ def dashboard(**kwargs): # noqa return self.render_template( "caravel/dashboard.html", dashboard=dash, + user_id=g.user.get_id(), templates=templates, dash_save_perm=self.can_access('can_save_dash', 'Caravel'), dash_edit_perm=check_ownership(dash, raise_if_false=False)) diff --git a/tests/core_tests.py b/tests/core_tests.py index ad39fe6b7c3eb..ef957ec46e2c4 100644 --- a/tests/core_tests.py +++ b/tests/core_tests.py @@ -201,6 +201,23 @@ def test_save_dash(self, username='admin'): resp = self.client.post(url, data=dict(data=json.dumps(data))) assert "SUCCESS" in resp.data.decode('utf-8') + def test_add_slices(self, username='admin'): + self.login(username=username) + dash = db.session.query(models.Dashboard).filter_by(slug="births").first() + new_slice = db.session.query(models.Slice).filter_by(slice_name="Mapbox Long/Lat").first() + existing_slice = db.session.query(models.Slice).filter_by(slice_name="Name Cloud").first() + data = { + "slice_ids": [new_slice.data["slice_id"], existing_slice.data["slice_id"]] + } + url = '/caravel/add_slices/{}/'.format(dash.id) + resp = self.client.post(url, data=dict(data=json.dumps(data))) + assert "SLICES ADDED" in resp.data.decode('utf-8') + + dash = db.session.query(models.Dashboard).filter_by(slug="births").first() + new_slice = db.session.query(models.Slice).filter_by(slice_name="Mapbox Long/Lat").first() + assert new_slice in dash.slices + assert len(set(dash.slices)) == len(dash.slices) + def test_gamma(self): self.login(username='gamma') resp = self.client.get('/slicemodelview/list/')