This is an automated email from the ASF dual-hosted git repository.
christine pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/incubator-superset.git
The following commit(s) were added to refs/heads/master by this push:
new ba9523c Adding dropdown to DatasourceControl and ability to change
datasource (#6816)
ba9523c is described below
commit ba9523c7c4d6e11876455eb0a1bf090e1ddacc7f
Author: michellethomas <[email protected]>
AuthorDate: Wed Feb 20 14:32:33 2019 -0800
Adding dropdown to DatasourceControl and ability to change datasource
(#6816)
* Adding dropdown to DatasourceControl and ability to change datasource
* Style fixes
* Adding unit tests for datasource/get endpoint
* Fixing issue with dropdown overflow and style changes
* Fixing issues rebasing metadata button and fixing sort for datasource
with no name
---
.../datasource/ChangeDatasourceModal_spec.jsx | 92 +++++++++++
.../explore/components/DatasourceControl_spec.jsx | 6 +
.../src/datasource/ChangeDatasourceModal.jsx | 173 +++++++++++++++++++++
.../components/controls/DatasourceControl.jsx | 122 ++++++++++-----
superset/assets/src/explore/main.css | 24 +++
superset/assets/stylesheets/superset.less | 3 +
superset/views/core.py | 2 +-
superset/views/datasource.py | 19 +++
tests/datasource_tests.py | 19 +++
9 files changed, 417 insertions(+), 43 deletions(-)
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 0000000..f55ab71
--- /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 = <ChangeDatasourceModal {...props} />;
+ 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 9604da1..47643a1 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 0000000..d7a2260
--- /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: (
+ <a
+ href="#"
+ onClick={this.selectDatasource.bind(this, ds)}
+ className="datasource-link"
+ >
+ {ds.name}
+ </a>
+ ),
+ 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 (
+ <Modal
+ show={show}
+ onHide={onHide}
+ onEnter={this.onEnterModal}
+ onExit={this.setSearchRef}
+ bsSize="lg"
+ >
+ <Modal.Header closeButton>
+ <Modal.Title>{t('Select a datasource')}</Modal.Title>
+ </Modal.Header>
+ <Modal.Body>
+ <div>
+ <FormControl
+ inputRef={(ref) => {
+ this.setSearchRef(ref);
+ }}
+ type="text"
+ bsSize="sm"
+ value={filter}
+ placeholder={t('Search / Filter')}
+ onChange={this.changeSearch}
+ />
+ </div>
+ {loading && <Loading />}
+ {datasources && (
+ <Table
+ columns={TABLE_COLUMNS}
+ className="table table-condensed"
+ data={datasources}
+ itemsPerPage={20}
+ filterable={TABLE_FILTERABLE}
+ filterBy={filter}
+ hideFilterInput
+ />
+ )}
+ </Modal.Body>
+ </Modal>
+ );
+ }
+}
+
+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 ae12fe4..910a5fd 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 {
</div>
);
}
+
render() {
+ const { menuExpanded, showChangeDatasourceModal, showEditDatasourceModal }
= this.state;
+ const { datasource, onChange, onDatasourceSave, value } = this.props;
return (
<div>
<ControlHeader {...this.props} />
- <OverlayTrigger
- placement="right"
- overlay={
- <Tooltip id={'error-tooltip'}>{t('Click to edit the
datasource')}</Tooltip>
- }
- >
- <Label onClick={this.toggleEditDatasourceModal} style={{ cursor:
'pointer' }} className="m-r-5">
- {this.props.datasource.name}
- </Label>
- </OverlayTrigger>
- <OverlayTrigger
- placement="right"
- overlay={
- <Tooltip id={'toggle-datasource-tooltip'}>
- {t('Expand/collapse datasource configuration')}
- </Tooltip>
- }
- >
- <a href="#">
- <i
- className={`fa fa-${this.state.showDatasource ? 'minus' :
'plus'}-square m-r-5`}
- onClick={this.toggleShowDatasource}
- />
- </a>
- </OverlayTrigger>
- {this.props.datasource.type === 'table' &&
+ <div className="btn-group label-dropdown">
<OverlayTrigger
placement="right"
overlay={
- <Tooltip id={'datasource-sqllab'}>
- {t('Explore this datasource in SQL Lab')}
- </Tooltip>
+ <Tooltip id={'error-tooltip'}>{t('Click to edit the
datasource')}</Tooltip>
}
>
- <a
- href={`/superset/sqllab?datasourceKey=${this.props.value}`}
- target="_blank"
- rel="noopener noreferrer"
+ <div className="btn-group">
+ <Label onClick={this.toggleEditDatasourceModal}
className="label-btn-label">
+ {datasource.name}
+ </Label>
+ </div>
+ </OverlayTrigger>
+ <DropdownButton
+ noCaret
+ title={
+ <span>
+ <i className={`float-right expander fa fa-angle-${menuExpanded
? 'up' : 'down'}`} />
+ </span>}
+ className="label label-btn m-r-5"
+ bsSize="sm"
+ id="datasource_menu"
+ >
+ <MenuItem
+ eventKey="3"
+ onClick={this.toggleEditDatasourceModal}
>
- <i className="fa fa-flask m-r-5" />
+ {t('Edit Datasource')}
+ </MenuItem>
+ {datasource.type === 'table' &&
+ <MenuItem
+ eventKey="3"
+ href={`/superset/sqllab?datasourceKey=${value}`}
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ {t('Explore in SQL Lab')}
+ </MenuItem>}
+ <MenuItem
+ eventKey="3"
+ onClick={this.toggleChangeDatasourceModal}
+ >
+ {t('Change Datasource')}
+ </MenuItem>
+ </DropdownButton>
+ <OverlayTrigger
+ placement="right"
+ overlay={
+ <Tooltip id={'toggle-datasource-tooltip'}>
+ {t('Expand/collapse datasource configuration')}
+ </Tooltip>
+ }
+ >
+ <a href="#">
+ <i
+ className={`fa fa-${this.state.showDatasource ? 'minus' :
'plus'}-square m-r-5 m-l-5 m-t-4`}
+ onClick={this.toggleShowDatasource}
+ />
</a>
- </OverlayTrigger>}
+ </OverlayTrigger>
+ </div>
<Collapse
in={this.state.showDatasource}>{this.renderDatasource()}</Collapse>
<DatasourceModal
- datasource={this.props.datasource}
- show={this.state.showEditDatasourceModal}
- onDatasourceSave={this.props.onDatasourceSave}
+ datasource={datasource}
+ show={showEditDatasourceModal}
+ onDatasourceSave={onDatasourceSave}
onHide={this.toggleEditDatasourceModal}
/>
+ <ChangeDatasourceModal
+ onDatasourceSave={onDatasourceSave}
+ onHide={this.toggleChangeDatasourceModal}
+ show={showChangeDatasourceModal}
+ onChange={onChange}
+ />
</div>
);
}
diff --git a/superset/assets/src/explore/main.css
b/superset/assets/src/explore/main.css
index 118b796..daf93d8 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 e2954a0..ebd648b 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 03e352f..053ed93 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 7b16ef9..eda4e8f 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/<datasource_type>/<datasource_id>/')
+ @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/<datasource_type>/<datasource_id>/')
@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 cc5c586..2aa1661 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')