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"
+ >
+
+ {datasource.type === 'table' &&
+ }
+
+
+
+ {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')