From 62fcdf2a92cc2fa93f77adbd650fc0a7031850a2 Mon Sep 17 00:00:00 2001 From: Maxime Beauchemin Date: Tue, 1 Aug 2017 12:08:00 -0700 Subject: [PATCH] [explore] DatasourceControl to pick datasource in modal (#3210) * [explore] DatasourceControl to pick datasource in modal Makes it easier to change datasource, also makes it such that the list of all datasources doesn't need to be loaded upfront. * Adding more metadata --- superset/assets/javascripts/SqlLab/index.jsx | 2 +- .../components/InfoTooltipWithTrigger.jsx | 9 +- .../explore/actions/exploreActions.js | 38 ----- .../explore/components/Control.jsx | 6 +- .../components/ExploreViewContainer.jsx | 3 - .../components/controls/DatasourceControl.jsx | 157 ++++++++++++++++++ .../components/controls/VizTypeControl.jsx | 20 ++- superset/assets/javascripts/explore/index.jsx | 1 + .../explore/reducers/exploreReducer.js | 19 --- .../javascripts/explore/stores/controls.jsx | 20 +-- .../components/DatasourceControl_spec.jsx | 32 ++++ .../explore/exploreActions_spec.js | 38 ----- .../reactable-pagination.css | 0 superset/assets/stylesheets/superset.css | 3 + superset/connectors/base/models.py | 24 +++ superset/connectors/druid/models.py | 4 + superset/connectors/sqla/models.py | 4 + superset/views/core.py | 3 +- 18 files changed, 257 insertions(+), 126 deletions(-) create mode 100644 superset/assets/javascripts/explore/components/controls/DatasourceControl.jsx create mode 100644 superset/assets/spec/javascripts/explore/components/DatasourceControl_spec.jsx rename superset/assets/{javascripts/SqlLab => stylesheets}/reactable-pagination.css (100%) diff --git a/superset/assets/javascripts/SqlLab/index.jsx b/superset/assets/javascripts/SqlLab/index.jsx index e292c2576c1ae..ba09924720177 100644 --- a/superset/assets/javascripts/SqlLab/index.jsx +++ b/superset/assets/javascripts/SqlLab/index.jsx @@ -11,7 +11,7 @@ import App from './components/App'; import { appSetup } from '../common'; import './main.css'; -import './reactable-pagination.css'; +import '../../stylesheets/reactable-pagination.css'; import '../components/FilterableTable/FilterableTableStyles.css'; appSetup(); diff --git a/superset/assets/javascripts/components/InfoTooltipWithTrigger.jsx b/superset/assets/javascripts/components/InfoTooltipWithTrigger.jsx index 07b4db473e3a6..85bc7fb50d1af 100644 --- a/superset/assets/javascripts/components/InfoTooltipWithTrigger.jsx +++ b/superset/assets/javascripts/components/InfoTooltipWithTrigger.jsx @@ -8,18 +8,23 @@ const propTypes = { tooltip: PropTypes.string.isRequired, icon: PropTypes.string, className: PropTypes.string, + onClick: PropTypes.func, }; const defaultProps = { icon: 'question-circle-o', }; -export default function InfoTooltipWithTrigger({ label, tooltip, icon, className }) { +export default function InfoTooltipWithTrigger({ label, tooltip, icon, className, onClick }) { return ( {tooltip}} > - + ); } diff --git a/superset/assets/javascripts/explore/actions/exploreActions.js b/superset/assets/javascripts/explore/actions/exploreActions.js index 6d8ed83488bac..d45acd5834b9e 100644 --- a/superset/assets/javascripts/explore/actions/exploreActions.js +++ b/superset/assets/javascripts/explore/actions/exploreActions.js @@ -16,11 +16,6 @@ export function setDatasource(datasource) { return { type: SET_DATASOURCE, datasource }; } -export const SET_DATASOURCES = 'SET_DATASOURCES'; -export function setDatasources(datasources) { - return { type: SET_DATASOURCES, datasources }; -} - export const FETCH_DATASOURCE_STARTED = 'FETCH_DATASOURCE_STARTED'; export function fetchDatasourceStarted() { return { type: FETCH_DATASOURCE_STARTED }; @@ -36,21 +31,6 @@ export function fetchDatasourceFailed(error) { return { type: FETCH_DATASOURCE_FAILED, error }; } -export const FETCH_DATASOURCES_STARTED = 'FETCH_DATASOURCES_STARTED'; -export function fetchDatasourcesStarted() { - return { type: FETCH_DATASOURCES_STARTED }; -} - -export const FETCH_DATASOURCES_SUCCEEDED = 'FETCH_DATASOURCES_SUCCEEDED'; -export function fetchDatasourcesSucceeded() { - return { type: FETCH_DATASOURCES_SUCCEEDED }; -} - -export const FETCH_DATASOURCES_FAILED = 'FETCH_DATASOURCES_FAILED'; -export function fetchDatasourcesFailed(error) { - return { type: FETCH_DATASOURCES_FAILED, error }; -} - export const RESET_FIELDS = 'RESET_FIELDS'; export function resetControls() { return { type: RESET_FIELDS }; @@ -83,24 +63,6 @@ export function fetchDatasourceMetadata(datasourceKey, alsoTriggerQuery = false) }; } -export function fetchDatasources() { - return function (dispatch) { - dispatch(fetchDatasourcesStarted()); - const url = '/superset/datasources/'; - $.ajax({ - type: 'GET', - url, - success: (data) => { - dispatch(setDatasources(data)); - dispatch(fetchDatasourcesSucceeded()); - }, - error(error) { - dispatch(fetchDatasourcesFailed(error.responseJSON.error)); - }, - }); - }; -} - export const TOGGLE_FAVE_STAR = 'TOGGLE_FAVE_STAR'; export function toggleFaveStar(isStarred) { return { type: TOGGLE_FAVE_STAR, isStarred }; diff --git a/superset/assets/javascripts/explore/components/Control.jsx b/superset/assets/javascripts/explore/components/Control.jsx index d9aaea72da330..b0dce3549fd95 100644 --- a/superset/assets/javascripts/explore/components/Control.jsx +++ b/superset/assets/javascripts/explore/components/Control.jsx @@ -1,24 +1,26 @@ import React from 'react'; import PropTypes from 'prop-types'; +import BoundsControl from './controls/BoundsControl'; import CheckboxControl from './controls/CheckboxControl'; +import DatasourceControl from './controls/DatasourceControl'; import FilterControl from './controls/FilterControl'; import HiddenControl from './controls/HiddenControl'; import SelectControl from './controls/SelectControl'; import TextAreaControl from './controls/TextAreaControl'; import TextControl from './controls/TextControl'; import VizTypeControl from './controls/VizTypeControl'; -import BoundsControl from './controls/BoundsControl'; const controlMap = { + BoundsControl, CheckboxControl, + DatasourceControl, FilterControl, HiddenControl, SelectControl, TextAreaControl, TextControl, VizTypeControl, - BoundsControl, }; const controlTypes = Object.keys(controlMap); diff --git a/superset/assets/javascripts/explore/components/ExploreViewContainer.jsx b/superset/assets/javascripts/explore/components/ExploreViewContainer.jsx index f015aa99b6a09..e8c904263bcee 100644 --- a/superset/assets/javascripts/explore/components/ExploreViewContainer.jsx +++ b/superset/assets/javascripts/explore/components/ExploreViewContainer.jsx @@ -33,9 +33,6 @@ class ExploreViewContainer extends React.Component { } componentDidMount() { - if (!this.props.standalone) { - this.props.actions.fetchDatasources(); - } window.addEventListener('resize', this.handleResize.bind(this)); this.triggerQueryIfNeeded(); } diff --git a/superset/assets/javascripts/explore/components/controls/DatasourceControl.jsx b/superset/assets/javascripts/explore/components/controls/DatasourceControl.jsx new file mode 100644 index 0000000000000..20e12a5f9e5dd --- /dev/null +++ b/superset/assets/javascripts/explore/components/controls/DatasourceControl.jsx @@ -0,0 +1,157 @@ +/* global notify */ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Table } from 'reactable'; +import { Label, FormControl, Modal, OverlayTrigger, Tooltip } from 'react-bootstrap'; + +import ControlHeader from '../ControlHeader'; +import InfoTooltipWithTrigger from '../../../components/InfoTooltipWithTrigger'; + +const propTypes = { + description: PropTypes.string, + label: PropTypes.string, + name: PropTypes.string.isRequired, + onChange: PropTypes.func, + value: PropTypes.string.isRequired, + datasource: PropTypes.object.isRequired, +}; + +const defaultProps = { + onChange: () => {}, +}; + +export default class DatasourceControl extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + showModal: false, + filter: '', + loading: true, + }; + this.toggleModal = this.toggleModal.bind(this); + this.changeSearch = this.changeSearch.bind(this); + this.setSearchRef = this.setSearchRef.bind(this); + this.onEnterModal = this.onEnterModal.bind(this); + } + onChange(vizType) { + this.props.onChange(vizType); + this.setState({ showModal: false }); + } + onEnterModal() { + if (this.searchRef) { + this.searchRef.focus(); + } + const url = '/superset/datasources/'; + const that = this; + if (!this.state.datasources) { + $.ajax({ + type: 'GET', + url, + success: (data) => { + const datasources = data.map(ds => ({ + rawName: ds.name, + connection: ds.connection, + schema: ds.schema, + name: ( + + {ds.name} + ), + type: ds.type, + })); + + that.setState({ loading: false, datasources }); + }, + error() { + that.setState({ loading: false }); + notify.error('Something went wrong while fetching the datasource list'); + }, + }); + } + } + setSearchRef(searchRef) { + this.searchRef = searchRef; + } + toggleModal() { + this.setState({ showModal: !this.state.showModal }); + } + changeSearch(event) { + this.setState({ filter: event.target.value }); + } + selectDatasource(datasourceId) { + this.setState({ showModal: false }); + this.props.onChange(datasourceId); + } + render() { + return ( +
+ + Click to point to another datasource + } + > + + + { + window.location = this.props.datasource.edit_url; + }} + /> + + + Select a datasource + + +
+ { this.setSearchRef(ref); }} + type="text" + bsSize="sm" + value={this.state.filter} + placeholder="Search / Filter" + onChange={this.changeSearch} + /> +
+ {this.state.loading && + Loading... + } + {this.state.datasources && + + } + + + ); + } +} + +DatasourceControl.propTypes = propTypes; +DatasourceControl.defaultProps = defaultProps; diff --git a/superset/assets/javascripts/explore/components/controls/VizTypeControl.jsx b/superset/assets/javascripts/explore/components/controls/VizTypeControl.jsx index 0b454ac86245f..0fc82660f2b40 100644 --- a/superset/assets/javascripts/explore/components/controls/VizTypeControl.jsx +++ b/superset/assets/javascripts/explore/components/controls/VizTypeControl.jsx @@ -1,6 +1,8 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { Label, Row, Col, FormControl, Modal } from 'react-bootstrap'; +import { + Label, Row, Col, FormControl, Modal, OverlayTrigger, + Tooltip } from 'react-bootstrap'; import visTypes from '../../stores/visTypes'; import ControlHeader from '../ControlHeader'; @@ -85,13 +87,17 @@ export default class VizTypeControl extends React.PureComponent {
edit - } /> - + Click to change visualization type + } + > + + { - const datasources = state.datasources || []; - return { - choices: datasources, - isLoading: datasources.length === 0, - rightNode: state.datasource ? - edit - : null, - }; - }, - description: '', + description: null, + mapStateToProps: state => ({ + datasource: state.datasource, + }), }, viz_type: { diff --git a/superset/assets/spec/javascripts/explore/components/DatasourceControl_spec.jsx b/superset/assets/spec/javascripts/explore/components/DatasourceControl_spec.jsx new file mode 100644 index 0000000000000..c46ded004a230 --- /dev/null +++ b/superset/assets/spec/javascripts/explore/components/DatasourceControl_spec.jsx @@ -0,0 +1,32 @@ +import React from 'react'; +import sinon from 'sinon'; +import { expect } from 'chai'; +import { describe, it, beforeEach } from 'mocha'; +import { shallow } from 'enzyme'; +import { Modal } from 'react-bootstrap'; +import DatasourceControl from '../../../../javascripts/explore/components/controls/DatasourceControl'; + +const defaultProps = { + name: 'datasource', + label: 'Datasource', + value: '1__table', + datasource: { + name: 'birth_names', + type: 'table', + uid: '1__table', + id: 1, + }, + onChange: sinon.spy(), +}; + +describe('DatasourceControl', () => { + let wrapper; + + beforeEach(() => { + wrapper = shallow(); + }); + + it('renders a Modal', () => { + expect(wrapper.find(Modal)).to.have.lengthOf(1); + }); +}); diff --git a/superset/assets/spec/javascripts/explore/exploreActions_spec.js b/superset/assets/spec/javascripts/explore/exploreActions_spec.js index 86173be4ecdfe..9fa02e4b12484 100644 --- a/superset/assets/spec/javascripts/explore/exploreActions_spec.js +++ b/superset/assets/spec/javascripts/explore/exploreActions_spec.js @@ -82,44 +82,6 @@ describe('fetching actions', () => { }); }); - describe('fetchDatasources', () => { - const makeRequest = () => { - request = actions.fetchDatasources(); - request(dispatch); - }; - - it('calls fetchDatasourcesStarted', () => { - makeRequest(); - expect(dispatch.args[0][0].type).to.equal(actions.FETCH_DATASOURCES_STARTED); - }); - - it('makes the ajax request', () => { - makeRequest(); - expect(ajaxStub.calledOnce).to.be.true; - }); - - it('calls correct url', () => { - const url = '/superset/datasources/'; - makeRequest(); - expect(ajaxStub.getCall(0).args[0].url).to.equal(url); - }); - - it('calls correct actions on error', () => { - ajaxStub.yieldsTo('error', { responseJSON: { error: 'error text' } }); - makeRequest(); - expect(dispatch.callCount).to.equal(2); - expect(dispatch.getCall(1).args[0].type).to.equal(actions.FETCH_DATASOURCES_FAILED); - }); - - it('calls correct actions on success', () => { - ajaxStub.yieldsTo('success', { data: '' }); - makeRequest(); - expect(dispatch.callCount).to.equal(3); - expect(dispatch.getCall(1).args[0].type).to.equal(actions.SET_DATASOURCES); - expect(dispatch.getCall(2).args[0].type).to.equal(actions.FETCH_DATASOURCES_SUCCEEDED); - }); - }); - describe('fetchDashboards', () => { const userID = 1; const mockDashboardData = { diff --git a/superset/assets/javascripts/SqlLab/reactable-pagination.css b/superset/assets/stylesheets/reactable-pagination.css similarity index 100% rename from superset/assets/javascripts/SqlLab/reactable-pagination.css rename to superset/assets/stylesheets/reactable-pagination.css diff --git a/superset/assets/stylesheets/superset.css b/superset/assets/stylesheets/superset.css index ef12c7eb36fda..20041330c9434 100644 --- a/superset/assets/stylesheets/superset.css +++ b/superset/assets/stylesheets/superset.css @@ -228,6 +228,9 @@ div.widget .slice_container { .m-r-5 { margin-right: 5px; } +.m-r-3 { + margin-right: 3px; +} .m-t-5 { margin-top: 5px; } diff --git a/superset/connectors/base/models.py b/superset/connectors/base/models.py index b32bb928a3c38..593c722d42bbc 100644 --- a/superset/connectors/base/models.py +++ b/superset/connectors/base/models.py @@ -68,6 +68,16 @@ def column_names(self): def main_dttm_col(self): return "timestamp" + @property + def connection(self): + """String representing the context of the Datasource""" + return None + + @property + def schema(self): + """String representing the schema of the Datasource (if it applies)""" + return None + @property def groupby_column_names(self): return sorted([c.column_name for c in self.columns if c.groupby]) @@ -107,6 +117,20 @@ def metrics_combo(self): for m in self.metrics], key=lambda x: x[1]) + @property + def short_data(self): + """Data representation of the datasource sent to the frontend""" + return { + 'edit_url': self.url, + 'id': self.id, + 'uid': self.uid, + 'schema': self.schema, + 'name': self.name, + 'type': self.type, + 'connection': self.connection, + 'creator': str(self.created_by), + } + @property def data(self): """Data representation of the datasource sent to the frontend""" diff --git a/superset/connectors/druid/models.py b/superset/connectors/druid/models.py index cc85a92b7fae4..6f88dd14630c7 100644 --- a/superset/connectors/druid/models.py +++ b/superset/connectors/druid/models.py @@ -354,6 +354,10 @@ class DruidDatasource(Model, BaseDatasource): def database(self): return self.cluster + @property + def connection(self): + return str(self.database) + @property def num_cols(self): return [c.column_name for c in self.columns if c.is_num] diff --git a/superset/connectors/sqla/models.py b/superset/connectors/sqla/models.py index 0d06bfbde04c8..b836a153e140d 100644 --- a/superset/connectors/sqla/models.py +++ b/superset/connectors/sqla/models.py @@ -192,6 +192,10 @@ class SqlaTable(Model, BaseDatasource): def __repr__(self): return self.name + @property + def connection(self): + return str(self.database) + @property def description_markeddown(self): return utils.markdown(self.description) diff --git a/superset/views/core.py b/superset/views/core.py index d5a31260c0607..e73ddc838049a 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -700,7 +700,8 @@ def json_response(self, obj, status=200): @expose("/datasources/") def datasources(self): datasources = ConnectorRegistry.get_all_datasources(db.session) - datasources = [(str(o.id) + '__' + o.type, repr(o)) for o in datasources] + datasources = [o.short_data for o in datasources] + datasources = sorted(datasources, key=lambda o: o['name']) return self.json_response(datasources) @has_access_api