superset-commits mailing list archives

Site index · List index
Message view « Date » · « Thread »
Top « Date » · « Thread »
From christ...@apache.org
Subject [incubator-superset] branch master updated: Adding dropdown to DatasourceControl and ability to change datasource (#6816)
Date Wed, 20 Feb 2019 22:32:40 GMT
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 <michelle.q.thomas@gmail.com>
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')


Mime
View raw message