diff --git a/superset/assets/spec/javascripts/datasource/ChangeDatasourceModal_spec.jsx b/superset/assets/spec/javascripts/datasource/ChangeDatasourceModal_spec.jsx new file mode 100644 index 000000000..f55ab71b1 --- /dev/null +++ b/superset/assets/spec/javascripts/datasource/ChangeDatasourceModal_spec.jsx @@ -0,0 +1,92 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import { Modal } from 'react-bootstrap'; +import configureStore from 'redux-mock-store'; +import { shallow } from 'enzyme'; +import fetchMock from 'fetch-mock'; +import thunk from 'redux-thunk'; +import sinon from 'sinon'; + +import ChangeDatasourceModal from '../../../src/datasource/ChangeDatasourceModal'; +import mockDatasource from '../../fixtures/mockDatasource'; + +const props = { + addDangerToast: () => {}, + onDatasourceSave: sinon.spy(), + onChange: () => {}, + onHide: () => {}, + show: true, +}; + +const datasource = mockDatasource['7__table']; +const datasourceData = { + id: datasource.name, + type: datasource.type, + uid: datasource.id, +}; + +const DATASOURCES_ENDPOINT = 'glob:*/superset/datasources/'; +const DATASOURCE_ENDPOINT = `glob:*/datasource/get/${datasourceData.type}/${datasourceData.id}`; +const DATASOURCES_PAYLOAD = { json: 'data' }; +const DATASOURCE_PAYLOAD = { new: 'data' }; + +describe('ChangeDatasourceModal', () => { + const mockStore = configureStore([thunk]); + const store = mockStore({}); + fetchMock.get(DATASOURCES_ENDPOINT, DATASOURCES_PAYLOAD); + + let wrapper; + let el; + let inst; + + beforeEach(() => { + el = ; + wrapper = shallow(el, { context: { store } }).dive(); + inst = wrapper.instance(); + }); + + it('is valid', () => { + expect(React.isValidElement(el)).toBe(true); + }); + + it('renders a Modal', () => { + expect(wrapper.find(Modal)).toHaveLength(1); + }); + + it('fetches datasources', (done) => { + inst.onEnterModal(); + setTimeout(() => { + expect(fetchMock.calls(DATASOURCES_ENDPOINT)).toHaveLength(1); + fetchMock.reset(); + done(); + }, 0); + }); + + it('changes the datasource', (done) => { + fetchMock.get(DATASOURCE_ENDPOINT, DATASOURCE_PAYLOAD); + inst.selectDatasource(datasourceData); + setTimeout(() => { + expect(fetchMock.calls(DATASOURCE_ENDPOINT)).toHaveLength(1); + expect(props.onDatasourceSave.getCall(0).args[0]).toEqual(DATASOURCE_PAYLOAD); + fetchMock.reset(); + done(); + }, 0); + }); +}); diff --git a/superset/assets/spec/javascripts/explore/components/DatasourceControl_spec.jsx b/superset/assets/spec/javascripts/explore/components/DatasourceControl_spec.jsx index 9604da151..47643a11a 100644 --- a/superset/assets/spec/javascripts/explore/components/DatasourceControl_spec.jsx +++ b/superset/assets/spec/javascripts/explore/components/DatasourceControl_spec.jsx @@ -21,6 +21,7 @@ import sinon from 'sinon'; import configureStore from 'redux-mock-store'; import { shallow } from 'enzyme'; import DatasourceModal from '../../../../src/datasource/DatasourceModal'; +import ChangeDatasourceModal from '../../../../src/datasource/ChangeDatasourceModal'; import DatasourceControl from '../../../../src/explore/components/controls/DatasourceControl'; const defaultProps = { @@ -53,4 +54,9 @@ describe('DatasourceControl', () => { const wrapper = setup(); expect(wrapper.find(DatasourceModal)).toHaveLength(1); }); + + it('renders a ChangeDatasourceModal', () => { + const wrapper = setup(); + expect(wrapper.find(ChangeDatasourceModal)).toHaveLength(1); + }); }); diff --git a/superset/assets/src/datasource/ChangeDatasourceModal.jsx b/superset/assets/src/datasource/ChangeDatasourceModal.jsx new file mode 100644 index 000000000..d7a226007 --- /dev/null +++ b/superset/assets/src/datasource/ChangeDatasourceModal.jsx @@ -0,0 +1,173 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Table } from 'reactable-arc'; +import { + FormControl, + Modal, +} from 'react-bootstrap'; +import { SupersetClient } from '@superset-ui/connection'; +import { t } from '@superset-ui/translation'; + +import getClientErrorObject from '../utils/getClientErrorObject'; +import Loading from '../components/Loading'; +import withToasts from '../messageToasts/enhancers/withToasts'; + +const propTypes = { + addDangerToast: PropTypes.func.isRequired, + onChange: PropTypes.func, + onDatasourceSave: PropTypes.func, + onHide: PropTypes.func, + show: PropTypes.bool.isRequired, +}; + +const defaultProps = { + onChange: () => {}, + onDatasourceSave: () => {}, + onHide: () => {}, +}; + +const TABLE_COLUMNS = ['name', 'type', 'schema', 'connection', 'creator']; +const TABLE_FILTERABLE = ['rawName', 'type', 'schema', 'connection', 'creator']; + +class ChangeDatasourceModal extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + loading: true, + datasources: null, + }; + this.setSearchRef = this.setSearchRef.bind(this); + this.onEnterModal = this.onEnterModal.bind(this); + this.selectDatasource = this.selectDatasource.bind(this); + this.changeSearch = this.changeSearch.bind(this); + } + + onEnterModal() { + if (this.searchRef) { + this.searchRef.focus(); + } + if (!this.state.datasources) { + SupersetClient.get({ + endpoint: '/superset/datasources/', + }) + .then(({ json }) => { + const datasources = json.map(ds => ({ + rawName: ds.name, + connection: ds.connection, + schema: ds.schema, + name: ( + + {ds.name} + + ), + type: ds.type, + })); + + this.setState({ loading: false, datasources }); + }) + .catch((response) => { + this.setState({ loading: false }); + getClientErrorObject(response).then(({ error }) => { + this.props.addDangerToast(error.error || error.statusText || error); + }); + }); + } + } + + setSearchRef(searchRef) { + this.searchRef = searchRef; + } + + changeSearch(event) { + this.setState({ filter: event.target.value }); + } + + selectDatasource(datasource) { + SupersetClient.get({ + endpoint: `/datasource/get/${datasource.type}/${datasource.id}`, + }) + .then(({ json }) => { + this.props.onDatasourceSave(json); + this.props.onChange(datasource.uid); + }) + .catch((response) => { + getClientErrorObject(response).then(({ error, message }) => { + const errorMessage = error ? error.error || error.statusText || error : message; + this.props.addDangerToast(errorMessage); + }); + }); + this.props.onHide(); + } + + render() { + const { datasources, filter, loading } = this.state; + const { show, onHide } = this.props; + + return ( + + + {t('Select a datasource')} + + +
+ { + this.setSearchRef(ref); + }} + type="text" + bsSize="sm" + value={filter} + placeholder={t('Search / Filter')} + onChange={this.changeSearch} + /> +
+ {loading && } + {datasources && ( + + )} + + + ); + } +} + +ChangeDatasourceModal.propTypes = propTypes; +ChangeDatasourceModal.defaultProps = defaultProps; + +export default withToasts(ChangeDatasourceModal); diff --git a/superset/assets/src/explore/components/controls/DatasourceControl.jsx b/superset/assets/src/explore/components/controls/DatasourceControl.jsx index ae12fe4dc..910a5fdef 100644 --- a/superset/assets/src/explore/components/controls/DatasourceControl.jsx +++ b/superset/assets/src/explore/components/controls/DatasourceControl.jsx @@ -21,7 +21,9 @@ import PropTypes from 'prop-types'; import { Col, Collapse, + DropdownButton, Label, + MenuItem, OverlayTrigger, Row, Tooltip, @@ -33,6 +35,7 @@ import ControlHeader from '../ControlHeader'; import ColumnOption from '../../../components/ColumnOption'; import MetricOption from '../../../components/MetricOption'; import DatasourceModal from '../../../datasource/DatasourceModal'; +import ChangeDatasourceModal from '../../../datasource/ChangeDatasourceModal'; const propTypes = { onChange: PropTypes.func, @@ -52,12 +55,12 @@ class DatasourceControl extends React.PureComponent { super(props); this.state = { showEditDatasourceModal: false, - loading: true, - showDatasource: false, - datasources: null, + showChangeDatasourceModal: false, + menuExpanded: false, }; - this.toggleShowDatasource = this.toggleShowDatasource.bind(this); + this.toggleChangeDatasourceModal = this.toggleChangeDatasourceModal.bind(this); this.toggleEditDatasourceModal = this.toggleEditDatasourceModal.bind(this); + this.toggleShowDatasource = this.toggleShowDatasource.bind(this); this.renderDatasource = this.renderDatasource.bind(this); } @@ -65,11 +68,18 @@ class DatasourceControl extends React.PureComponent { this.setState(({ showDatasource }) => ({ showDatasource: !showDatasource })); } + toggleChangeDatasourceModal() { + this.setState(({ showChangeDatasourceModal }) => ({ + showChangeDatasourceModal: !showChangeDatasourceModal, + })); + } + toggleEditDatasourceModal() { this.setState(({ showEditDatasourceModal }) => ({ showEditDatasourceModal: !showEditDatasourceModal, })); } + renderDatasource() { const datasource = this.props.datasource; return ( @@ -103,59 +113,87 @@ class DatasourceControl extends React.PureComponent { ); } + render() { + const { menuExpanded, showChangeDatasourceModal, showEditDatasourceModal } = this.state; + const { datasource, onChange, onDatasourceSave, value } = this.props; return (
- {t('Click to edit the datasource')} - } - > - - - - {t('Expand/collapse datasource configuration')} - - } - > - - - - - {this.props.datasource.type === 'table' && +
- {t('Explore this datasource in SQL Lab')} + {t('Click to edit the datasource')} + } + > +
+ +
+
+ + + } + className="label label-btn m-r-5" + bsSize="sm" + id="datasource_menu" + > + + {t('Edit Datasource')} + + {datasource.type === 'table' && + + {t('Explore in SQL Lab')} + } + + {t('Change Datasource')} + + + + {t('Expand/collapse datasource configuration')} } > - - + + - } + +
{this.renderDatasource()} +
); } diff --git a/superset/assets/src/explore/main.css b/superset/assets/src/explore/main.css index 118b79672..daf93d8a3 100644 --- a/superset/assets/src/explore/main.css +++ b/superset/assets/src/explore/main.css @@ -193,6 +193,30 @@ font-weight: normal; } +.btn.label-btn { + background-color: #808e95; + font-weight: normal; + color: #fff; + padding: 5px 4px 4px; + border:0; +} + +.label-dropdown ul.dropdown-menu { + position: fixed; + top: auto; + left: auto; + margin: 20px 0 0; +} + +.label-btn:hover, .label-btn-label:hover { + background-color: #667177; + color: #fff; +} + +.label-btn-label { + cursor: pointer; +} + .adhoc-filter-simple-column-dropdown { margin-top: 20px; } diff --git a/superset/assets/stylesheets/superset.less b/superset/assets/stylesheets/superset.less index e2954a07d..ebd648b09 100644 --- a/superset/assets/stylesheets/superset.less +++ b/superset/assets/stylesheets/superset.less @@ -305,6 +305,9 @@ table.table-no-hover tr:hover { .m-r-3 { margin-right: 3px; } +.m-t-4 { + margin-top: 4px; +} .m-t-5 { margin-top: 5px; } diff --git a/superset/views/core.py b/superset/views/core.py index 03e352ffe..053ed93dd 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -810,7 +810,7 @@ class Superset(BaseSupersetView): @expose('/datasources/') def datasources(self): datasources = ConnectorRegistry.get_all_datasources(db.session) - datasources = [o.short_data for o in datasources] + datasources = [o.short_data for o in datasources if o.short_data.get('name')] datasources = sorted(datasources, key=lambda o: o['name']) return self.json_response(datasources) diff --git a/superset/views/datasource.py b/superset/views/datasource.py index 7b16ef910..eda4e8f7b 100644 --- a/superset/views/datasource.py +++ b/superset/views/datasource.py @@ -55,6 +55,25 @@ class Datasource(BaseSupersetView): db.session.commit() return self.json_response(data) + @expose('/get///') + @has_access_api + def get(self, datasource_type, datasource_id): + orm_datasource = ConnectorRegistry.get_datasource( + datasource_type, datasource_id, db.session) + + if not orm_datasource: + return json_error_response( + 'This datasource does not exist', + status='400', + ) + elif not orm_datasource.data: + return json_error_response( + 'Error fetching datasource data.', + status='500', + ) + + return self.json_response(orm_datasource.data) + @expose('/external_metadata///') @has_access_api def external_metadata(self, datasource_type=None, datasource_id=None): diff --git a/tests/datasource_tests.py b/tests/datasource_tests.py index cc5c586c9..2aa1661b7 100644 --- a/tests/datasource_tests.py +++ b/tests/datasource_tests.py @@ -64,3 +64,22 @@ class DatasourceTests(SupersetTestCase): self.compare_lists(datasource_post[k], resp[k], 'metric_name') else: self.assertEquals(resp[k], datasource_post[k]) + + def test_get_datasource(self): + self.login(username='admin') + tbl = self.get_table_by_name('birth_names') + url = f'/datasource/get/{tbl.type}/{tbl.id}/' + resp = self.get_json_resp(url) + self.assertEquals(resp.get('type'), 'table') + col_names = {o.get('column_name') for o in resp['columns']} + self.assertEquals( + col_names, + {'sum_boys', 'num', 'gender', 'name', 'ds', 'state', + 'sum_girls', 'num_california'}, + ) + + def test_get_datasource_failed(self): + self.login(username='admin') + url = f'/datasource/get/druid/500000/' + resp = self.get_json_resp(url) + self.assertEquals(resp.get('error'), 'This datasource does not exist')