diff --git a/superset/assets/javascripts/explore/components/QueryAndSaveBtns.jsx b/superset/assets/javascripts/explore/components/QueryAndSaveBtns.jsx index d6e25a0528..08259e9cf4 100644 --- a/superset/assets/javascripts/explore/components/QueryAndSaveBtns.jsx +++ b/superset/assets/javascripts/explore/components/QueryAndSaveBtns.jsx @@ -4,9 +4,14 @@ import classnames from 'classnames'; const propTypes = { canAdd: PropTypes.string.isRequired, onQuery: PropTypes.func.isRequired, + onSave: PropTypes.func, }; -export default function QueryAndSaveBtns({ canAdd, onQuery }) { +const defaultProps = { + onSave: () => {}, +}; + +export default function QueryAndSaveBtns({ canAdd, onQuery, onSave }) { const saveClasses = classnames('btn btn-default btn-sm', { 'disabled disabledButton': canAdd !== 'True', }); @@ -21,6 +26,7 @@ export default function QueryAndSaveBtns({ canAdd, onQuery }) { className={saveClasses} data-target="#save_modal" data-toggle="modal" + onClick={onSave} > Save as @@ -29,3 +35,4 @@ export default function QueryAndSaveBtns({ canAdd, onQuery }) { } QueryAndSaveBtns.propTypes = propTypes; +QueryAndSaveBtns.defaultProps = defaultProps; diff --git a/superset/assets/javascripts/explorev2/actions/exploreActions.js b/superset/assets/javascripts/explorev2/actions/exploreActions.js index 8d0728fbba..a1a347dd7d 100644 --- a/superset/assets/javascripts/explorev2/actions/exploreActions.js +++ b/superset/assets/javascripts/explorev2/actions/exploreActions.js @@ -155,3 +155,53 @@ export const REMOVE_CHART_ALERT = 'REMOVE_CHART_ALERT'; export function removeChartAlert() { return { type: REMOVE_CHART_ALERT }; } + +export const FETCH_DASHBOARDS_SUCCEEDED = 'FETCH_DASHBOARDS_SUCCEEDED'; +export function fetchDashboardsSucceeded(choices) { + return { type: FETCH_DASHBOARDS_SUCCEEDED, choices }; +} + +export const FETCH_DASHBOARDS_FAILED = 'FETCH_DASHBOARDS_FAILED'; +export function fetchDashboardsFailed(userId) { + return { type: FETCH_FAILED, userId }; +} + +export function fetchDashboards(userId) { + return function (dispatch) { + const url = '/dashboardmodelviewasync/api/read?_flt_0_owners=' + userId; + $.get(url, function (data, status) { + if (status === 'success') { + const choices = []; + for (let i = 0; i < data.pks.length; i++) { + choices.push({ value: data.pks[i], label: data.result[i].dashboard_title }); + } + dispatch(fetchDashboardsSucceeded(choices)); + } else { + dispatch(fetchDashboardsFailed(userId)); + } + }); + }; +} + +export const SAVE_SLICE_FAILED = 'SAVE_SLICE_FAILED'; +export function saveSliceFailed() { + return { type: SAVE_SLICE_FAILED }; +} + +export const REMOVE_SAVE_MODAL_ALERT = 'REMOVE_SAVE_MODAL_ALERT'; +export function removeSaveModalAlert() { + return { type: REMOVE_SAVE_MODAL_ALERT }; +} + +export function saveSlice(url) { + return function (dispatch) { + $.get(url, (data, status) => { + if (status === 'success') { + // Go to new slice url or dashboard url + window.location = data; + } else { + dispatch(saveSliceFailed()); + } + }); + }; +} diff --git a/superset/assets/javascripts/explorev2/components/ChartContainer.jsx b/superset/assets/javascripts/explorev2/components/ChartContainer.jsx index 5ae9a6574d..00932a07c5 100644 --- a/superset/assets/javascripts/explorev2/components/ChartContainer.jsx +++ b/superset/assets/javascripts/explorev2/components/ChartContainer.jsx @@ -215,8 +215,4 @@ function mapStateToProps(state) { }; } -function mapDispatchToProps() { - return {}; -} - -export default connect(mapStateToProps, mapDispatchToProps)(ChartContainer); +export default connect(mapStateToProps, () => ({}))(ChartContainer); diff --git a/superset/assets/javascripts/explorev2/components/ControlPanelsContainer.jsx b/superset/assets/javascripts/explorev2/components/ControlPanelsContainer.jsx index c792b28e5d..0e42825b41 100644 --- a/superset/assets/javascripts/explorev2/components/ControlPanelsContainer.jsx +++ b/superset/assets/javascripts/explorev2/components/ControlPanelsContainer.jsx @@ -108,8 +108,6 @@ function mapStateToProps(state) { alert: state.controlPanelAlert, isDatasourceMetaLoading: state.isDatasourceMetaLoading, fields: state.fields, - datasource_type: state.datasource_type, - form_data: state.viz.form_data, }; } diff --git a/superset/assets/javascripts/explorev2/components/ExploreViewContainer.jsx b/superset/assets/javascripts/explorev2/components/ExploreViewContainer.jsx index 2b3442fd94..2154f36a19 100644 --- a/superset/assets/javascripts/explorev2/components/ExploreViewContainer.jsx +++ b/superset/assets/javascripts/explorev2/components/ExploreViewContainer.jsx @@ -5,6 +5,7 @@ import * as actions from '../actions/exploreActions'; import { connect } from 'react-redux'; import ChartContainer from './ChartContainer'; import ControlPanelsContainer from './ControlPanelsContainer'; +import SaveModal from './SaveModal'; import QueryAndSaveBtns from '../../explore/components/QueryAndSaveBtns'; const $ = require('jquery'); @@ -20,6 +21,7 @@ class ExploreViewContainer extends React.Component { super(props); this.state = { height: this.getHeight(), + showModal: false, }; } @@ -63,6 +65,10 @@ class ExploreViewContainer extends React.Component { this.props.datasource_type, this.props.form_data.datasource, data); } + toggleModal() { + this.setState({ showModal: !this.state.showModal }); + } + render() { return (
+ {this.state.showModal && + + }


@@ -112,6 +128,5 @@ function mapDispatchToProps(dispatch) { }; } -export { ControlPanelsContainer }; - +export { ExploreViewContainer }; export default connect(mapStateToProps, mapDispatchToProps)(ExploreViewContainer); diff --git a/superset/assets/javascripts/explorev2/components/FieldSet.jsx b/superset/assets/javascripts/explorev2/components/FieldSet.jsx index 03b6ded919..7097028d18 100644 --- a/superset/assets/javascripts/explorev2/components/FieldSet.jsx +++ b/superset/assets/javascripts/explorev2/components/FieldSet.jsx @@ -14,7 +14,11 @@ const propTypes = { places: PropTypes.number, validators: PropTypes.any, onChange: React.PropTypes.func, - value: PropTypes.oneOfType([PropTypes.string, PropTypes.bool, PropTypes.array]).isRequired, + value: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + PropTypes.bool, + PropTypes.array]).isRequired, }; const defaultProps = { diff --git a/superset/assets/javascripts/explorev2/components/SaveModal.js b/superset/assets/javascripts/explorev2/components/SaveModal.js new file mode 100644 index 0000000000..08b960122d --- /dev/null +++ b/superset/assets/javascripts/explorev2/components/SaveModal.js @@ -0,0 +1,241 @@ +/* eslint camel-case: 0 */ +import React, { PropTypes } from 'react'; +import $ from 'jquery'; +import { Modal, Alert, Button, Radio } from 'react-bootstrap'; +import Select from 'react-select'; +import { connect } from 'react-redux'; + +const propTypes = { + can_edit: PropTypes.bool, + onHide: PropTypes.func.isRequired, + actions: PropTypes.object.isRequired, + form_data: PropTypes.object, + datasource_type: PropTypes.string.isRequired, + user_id: PropTypes.string.isRequired, + dashboards: PropTypes.array.isRequired, + alert: PropTypes.string, +}; + +class SaveModal extends React.Component { + constructor(props) { + super(props); + this.state = { + saveToDashboardId: null, + newDashboardName: '', + newSliceName: '', + dashboards: [], + alert: null, + action: 'overwrite', + addToDash: 'noSave', + }; + } + componentDidMount() { + this.props.actions.fetchDashboards(this.props.user_id); + } + onChange(name, event) { + switch (name) { + case 'newSliceName': + this.setState({ newSliceName: event.target.value }); + break; + case 'saveToDashboardId': + this.setState({ saveToDashboardId: event.value }); + this.changeDash('existing'); + break; + case 'newDashboardName': + this.setState({ newDashboardName: event.target.value }); + break; + default: + break; + } + } + changeAction(action) { + this.setState({ action }); + } + changeDash(dash) { + this.setState({ addToDash: dash }); + } + saveOrOverwrite(gotodash) { + this.setState({ alert: null }); + this.props.actions.removeSaveModalAlert(); + const params = {}; + const sliceParams = {}; + params.datasource_id = this.props.form_data.datasource; + params.datasource_type = this.props.datasource_type; + params.datasource_name = this.props.form_data.datasource_name; + + let sliceName = null; + sliceParams.action = this.state.action; + if (sliceParams.action === 'saveas') { + sliceName = this.state.newSliceName; + if (sliceName === '') { + this.setState({ alert: 'Please enter a slice name' }); + return; + } + sliceParams.slice_name = sliceName; + } else { + sliceParams.slice_name = this.props.form_data.slice_name; + } + + Object.keys(this.props.form_data).forEach((field) => { + if (this.props.form_data[field] !== null && field !== 'slice_name') { + params[field] = this.props.form_data[field]; + } + }); + + const addToDash = this.state.addToDash; + sliceParams.add_to_dash = addToDash; + let dashboard = null; + switch (addToDash) { + case ('existing'): + dashboard = this.state.saveToDashboardId; + if (!dashboard) { + this.setState({ alert: 'Please select a dashboard' }); + return; + } + sliceParams.save_to_dashboard_id = dashboard; + break; + case ('new'): + dashboard = this.state.newDashboardName; + if (dashboard === '') { + this.setState({ alert: 'Please enter a dashboard name' }); + return; + } + sliceParams.new_dashboard_name = dashboard; + break; + default: + dashboard = null; + } + params.V2 = true; + sliceParams.goto_dash = gotodash; + const baseUrl = '/superset/explore/' + + `${this.props.datasource_type}/${this.props.form_data.datasource}/`; + const saveUrl = `${baseUrl}?${$.param(params, true)}&${$.param(sliceParams, true)}`; + this.props.actions.saveSlice(saveUrl); + this.props.onHide(); + } + removeAlert() { + if (this.props.alert) { + this.props.actions.removeSaveModalAlert(); + } + this.setState({ alert: null }); + } + render() { + return ( + + + + Save A Slice + + + + {(this.state.alert || this.props.alert) && + + {this.state.alert ? this.state.alert : this.props.alert} + + + } + + {`Overwrite slice ${this.props.form_data.slice_name}`} + + + Save as   + + + + +
+
+ + + Do not add to a dashboard + + + + Add slice to existing dashboard + + +
+ + + + + +
+ ); + } +} + +SaveModal.propTypes = propTypes; + +function mapStateToProps(state) { + return { + can_edit: state.can_edit, + user_id: state.user_id, + dashboards: state.dashboards, + alert: state.saveModalAlert, + }; +} + +export { SaveModal }; +export default connect(mapStateToProps, () => ({}))(SaveModal); diff --git a/superset/assets/javascripts/explorev2/index.jsx b/superset/assets/javascripts/explorev2/index.jsx index 2b7fc14583..e30b41acf8 100644 --- a/superset/assets/javascripts/explorev2/index.jsx +++ b/superset/assets/javascripts/explorev2/index.jsx @@ -13,10 +13,12 @@ const bootstrapData = JSON.parse(exploreViewContainer.getAttribute('data-bootstr import { exploreReducer } from './reducers/exploreReducer'; const bootstrappedState = Object.assign(initialState(bootstrapData.viz.form_data.viz_type), { + can_edit: bootstrapData.can_edit, can_download: bootstrapData.can_download, datasources: bootstrapData.datasources, datasource_type: bootstrapData.datasource_type, viz: bootstrapData.viz, + user_id: bootstrapData.user_id, }); bootstrappedState.viz.form_data.datasource = parseInt(bootstrapData.datasource_id, 10); bootstrappedState.viz.form_data.datasource_name = bootstrapData.datasource_name; diff --git a/superset/assets/javascripts/explorev2/reducers/exploreReducer.js b/superset/assets/javascripts/explorev2/reducers/exploreReducer.js index 1316c0bca2..da93cdf269 100644 --- a/superset/assets/javascripts/explorev2/reducers/exploreReducer.js +++ b/superset/assets/javascripts/explorev2/reducers/exploreReducer.js @@ -27,6 +27,16 @@ export const exploreReducer = function (state, action) { [actions.REMOVE_CONTROL_PANEL_ALERT]() { return Object.assign({}, state, { controlPanelAlert: null }); }, + + [actions.FETCH_DASHBOARDS_SUCCEEDED]() { + return Object.assign({}, state, { dashboards: action.choices }); + }, + + [actions.FETCH_DASHBOARDS_FAILED]() { + return Object.assign({}, state, + { saveModalAlert: `fetching dashboards failed for ${action.userId}` }); + }, + [actions.SET_FIELD_OPTIONS]() { const newState = Object.assign({}, state); const optionsByFieldName = action.options; @@ -66,6 +76,9 @@ export const exploreReducer = function (state, action) { newFormData.slice_name = state.viz.form_data.slice_name; newFormData.viz_type = state.viz.form_data.viz_type; } + if (action.key === 'viz_type') { + newFormData.previous_viz_type = state.viz.form_data.viz_type; + } newFormData[action.key] = action.value ? action.value : (!state.viz.form_data[action.key]); return Object.assign( {}, @@ -99,6 +112,12 @@ export const exploreReducer = function (state, action) { [actions.REMOVE_CHART_ALERT]() { return Object.assign({}, state, { chartAlert: null }); }, + [actions.SAVE_SLICE_FAILED]() { + return Object.assign({}, state, { saveModalAlert: 'Failed to save slice' }); + }, + [actions.REMOVE_SAVE_MODAL_ALERT]() { + return Object.assign({}, state, { saveModalAlert: null }); + }, }; if (action.type in actionHandlers) { return actionHandlers[action.type](); diff --git a/superset/assets/javascripts/explorev2/stores/store.js b/superset/assets/javascripts/explorev2/stores/store.js index 5d86903286..4ceefe71ed 100644 --- a/superset/assets/javascripts/explorev2/stores/store.js +++ b/superset/assets/javascripts/explorev2/stores/store.js @@ -750,7 +750,7 @@ export const fields = { type: 'SelectMultipleSortableField', label: 'Metrics', choices: [], - default: null, + default: [], description: 'One or many metrics to display', }, @@ -758,6 +758,7 @@ export const fields = { type: 'SelectMultipleSortableField', label: 'Ordering', choices: [], + default: [], description: 'One or many metrics to display', }, @@ -931,6 +932,7 @@ export const fields = { type: 'SelectMultipleSortableField', label: 'Group by', choices: [], + default: [], description: 'One or many fields to group by', }, @@ -938,6 +940,7 @@ export const fields = { type: 'SelectMultipleSortableField', label: 'Columns', choices: [], + default: [], description: 'One or many fields to pivot as columns', }, @@ -945,6 +948,7 @@ export const fields = { type: 'SelectMultipleSortableField', label: 'Columns', choices: [], + default: [], description: 'Columns to display', }, @@ -1552,6 +1556,7 @@ export const fields = { type: 'SelectMultipleSortableField', label: 'label', choices: [], + default: [], description: '`count` is COUNT(*) if a group by is used. ' + 'Numerical columns will be aggregated with the aggregator. ' + 'Non-numerical columns will be used to label points. ' + @@ -1704,6 +1709,7 @@ export function defaultViz(vizType) { export function initialState(vizType = 'table') { return { + dashboards: [], isDatasourceMetaLoading: false, datasources: null, datasource_type: null, diff --git a/superset/assets/spec/javascripts/explorev2/components/SaveModal_spec.js b/superset/assets/spec/javascripts/explorev2/components/SaveModal_spec.js new file mode 100644 index 0000000000..dd86bdf616 --- /dev/null +++ b/superset/assets/spec/javascripts/explorev2/components/SaveModal_spec.js @@ -0,0 +1,36 @@ +import React from 'react'; +import { expect } from 'chai'; +import { describe, it, beforeEach } from 'mocha'; +import { shallow } from 'enzyme'; +import { defaultFormData } from '../../../../javascripts/explorev2/stores/store'; +import { SaveModal } from '../../../../javascripts/explorev2/components/SaveModal'; +import { Modal, Button, Radio } from 'react-bootstrap'; +import sinon from 'sinon'; + +const defaultProps = { + can_edit: true, + onHide: () => ({}), + actions: { + saveSlice: sinon.spy(), + }, + form_data: defaultFormData, + datasource_id: 1, + datasource_name: 'birth_names', + datasource_type: 'table', + user_id: 1, +}; + +describe('SaveModal', () => { + let wrapper; + + beforeEach(() => { + wrapper = shallow(); + }); + + it('renders a Modal with 7 inputs and 2 buttons', () => { + expect(wrapper.find(Modal)).to.have.lengthOf(1); + expect(wrapper.find('input')).to.have.lengthOf(2); + expect(wrapper.find(Button)).to.have.lengthOf(2); + expect(wrapper.find(Radio)).to.have.lengthOf(5); + }); +}); diff --git a/superset/views.py b/superset/views.py index 597d0fbdd8..6e66b9017e 100755 --- a/superset/views.py +++ b/superset/views.py @@ -1456,7 +1456,8 @@ class Superset(BaseSupersetView): # TODO use form processing form wtforms d = args.to_dict(flat=False) del d['action'] - del d['previous_viz_type'] + if 'previous_viz_type' in d: + del d['previous_viz_type'] as_list = ('metrics', 'groupby', 'columns', 'all_columns', 'mapbox_label', 'order_by_cols') @@ -1515,8 +1516,12 @@ class Superset(BaseSupersetView): db.session.commit() if request.args.get('goto_dash') == 'true': + if request.args.get('V2') == 'true': + return dash.url return redirect(dash.url) else: + if request.args.get('V2') == 'true': + return slc.slice_url return redirect(slc.slice_url) def save_slice(self, slc):