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}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
- }
-}
-
-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: br>" + 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: br>" + 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}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+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 (
+
+
+
+
+
+
{this.props.title}
+
+
+ {this.props.modalContent}
+
+
+
+ {this.props.customButtons}
+
+
+
+
+ );
+ }
+}
+
+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 = (
+
+
+
+ {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/')