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
This commit is contained in:
michellethomas 2019-02-20 14:32:33 -08:00 committed by Christine Chambers
parent c04c0cd8f0
commit ba9523c7c4
9 changed files with 417 additions and 43 deletions

View File

@ -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);
});
});

View File

@ -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);
});
});

View File

@ -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);

View File

@ -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 id={'error-tooltip'}>{t('Click to edit the datasource')}</Tooltip>
}
>
<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}
>
{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={`/superset/sqllab?datasourceKey=${this.props.value}`}
target="_blank"
rel="noopener noreferrer"
>
<i className="fa fa-flask m-r-5" />
<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>
);
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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)

View File

@ -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):

View File

@ -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')