Save modal component for explore v2 (#1612)

* Added specs for SaveModal

* Move datasource_id and datasource_name to form_data

* Add comments

* Deleted redundant fetchDashboard

* Replcae has_key for python3

* More react and less jquery

* Added alert for save slice

* Small changes based on comments

* Use react bootstrap
This commit is contained in:
vera-liu 2016-11-18 14:56:02 -08:00 committed by GitHub
parent dc25bc6f4d
commit 38e94b9e43
12 changed files with 392 additions and 13 deletions

View File

@ -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}
>
<i className="fa fa-plus-circle"></i> Save as
</button>
@ -29,3 +35,4 @@ export default function QueryAndSaveBtns({ canAdd, onQuery }) {
}
QueryAndSaveBtns.propTypes = propTypes;
QueryAndSaveBtns.defaultProps = defaultProps;

View File

@ -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());
}
});
};
}

View File

@ -215,8 +215,4 @@ function mapStateToProps(state) {
};
}
function mapDispatchToProps() {
return {};
}
export default connect(mapStateToProps, mapDispatchToProps)(ChartContainer);
export default connect(mapStateToProps, () => ({}))(ChartContainer);

View File

@ -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,
};
}

View File

@ -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 (
<div
@ -72,16 +78,26 @@ class ExploreViewContainer extends React.Component {
overflow: 'hidden',
}}
>
{this.state.showModal &&
<SaveModal
onHide={this.toggleModal.bind(this)}
actions={this.props.actions}
form_data={this.props.form_data}
datasource_type={this.props.datasource_type}
/>
}
<div className="row">
<div className="col-sm-4">
<QueryAndSaveBtns
canAdd="True"
onQuery={this.onQuery.bind(this)}
onSave={this.toggleModal.bind(this)}
/>
<br /><br />
<ControlPanelsContainer
actions={this.props.actions}
form_data={this.props.form_data}
datasource_type={this.props.datasource_type}
/>
</div>
<div className="col-sm-8">
@ -112,6 +128,5 @@ function mapDispatchToProps(dispatch) {
};
}
export { ControlPanelsContainer };
export { ExploreViewContainer };
export default connect(mapStateToProps, mapDispatchToProps)(ExploreViewContainer);

View File

@ -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 = {

View File

@ -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 (
<Modal
show
onHide={this.props.onHide}
bsStyle="large"
>
<Modal.Header closeButton>
<Modal.Title>
Save A Slice
</Modal.Title>
</Modal.Header>
<Modal.Body>
{(this.state.alert || this.props.alert) &&
<Alert>
{this.state.alert ? this.state.alert : this.props.alert}
<i
className="fa fa-close pull-right"
onClick={this.removeAlert.bind(this)}
style={{ cursor: 'pointer' }}
/>
</Alert>
}
<Radio
disabled={!this.props.can_edit}
checked={this.state.action === 'overwrite'}
onChange={this.changeAction.bind(this, 'overwrite')}
>
{`Overwrite slice ${this.props.form_data.slice_name}`}
</Radio>
<Radio
inline
checked={this.state.action === 'saveas'}
onChange={this.changeAction.bind(this, 'saveas')}
> Save as &nbsp;
</Radio>
<input
name="new_slice_name"
placeholder="[slice name]"
onChange={this.onChange.bind(this, 'newSliceName')}
onFocus={this.changeAction.bind(this, 'saveas')}
/>
<br />
<hr />
<Radio
checked={this.state.addToDash === 'noSave'}
onChange={this.changeDash.bind(this, 'noSave')}
>
Do not add to a dashboard
</Radio>
<Radio
inline
checked={this.state.addToDash === 'existing'}
onChange={this.changeDash.bind(this, 'existing')}
>
Add slice to existing dashboard
</Radio>
<Select
options={this.props.dashboards}
onChange={this.onChange.bind(this, 'saveToDashboardId')}
autoSize={false}
value={this.state.saveToDashboardId}
/>
<Radio
inline
checked={this.state.addToDash === 'new'}
onChange={this.changeDash.bind(this, 'new')}
>
Add to new dashboard &nbsp;
</Radio>
<input
onChange={this.onChange.bind(this, 'newDashboardName')}
onFocus={this.changeDash.bind(this, 'new')}
placeholder="[dashboard name]"
/>
</Modal.Body>
<Modal.Footer>
<Button
type="button"
id="btn_modal_save"
className="btn pull-left"
onClick={this.saveOrOverwrite.bind(this)}
>
Save
</Button>
<Button
type="button"
id="btn_modal_save_goto_dash"
className="btn btn-primary pull-left gotodash"
disabled={this.state.addToDash === 'noSave'}
onClick={this.saveOrOverwrite.bind(this, true)}
>
Save & go to dashboard
</Button>
</Modal.Footer>
</Modal>
);
}
}
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);

View File

@ -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;

View File

@ -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]();

View File

@ -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,

View File

@ -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(<SaveModal {...defaultProps} />);
});
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);
});
});

View File

@ -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):