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:
parent
c04c0cd8f0
commit
ba9523c7c4
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Reference in New Issue