feat: update dataset editor modal (#10347)

This commit is contained in:
Lily Kuang 2020-07-28 15:53:20 -07:00 committed by GitHub
parent e89e60df76
commit 39fad8575c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 541 additions and 510 deletions

View File

@ -17,17 +17,23 @@
* under the License.
*/
import React from 'react';
import { mount } from 'enzyme';
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 { supersetTheme, ThemeProvider } from '@superset-ui/style';
import { act } from 'react-dom/test-utils';
import ChangeDatasourceModal from 'src/datasource/ChangeDatasourceModal';
import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
import mockDatasource from '../../fixtures/mockDatasource';
const props = {
const mockStore = configureStore([thunk]);
const store = mockStore({});
const mockedProps = {
addDangerToast: () => {},
onDatasourceSave: sinon.spy(),
onChange: () => {},
@ -37,62 +43,53 @@ const props = {
const datasource = mockDatasource['7__table'];
const datasourceData = {
id: datasource.name,
id: datasource.id,
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' };
fetchMock.get(DATASOURCES_ENDPOINT, [mockDatasource['7__table']]);
fetchMock.get(DATASOURCE_ENDPOINT, DATASOURCE_PAYLOAD);
async function mountAndWait(props = mockedProps) {
const mounted = mount(<ChangeDatasourceModal {...props} />, {
context: { store },
wrappingComponent: ThemeProvider,
wrappingComponentProps: { theme: supersetTheme },
});
await waitForComponentToPaint(mounted);
return mounted;
}
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();
beforeEach(async () => {
wrapper = await mountAndWait();
});
it('is valid', () => {
expect(React.isValidElement(el)).toBe(true);
it('renders', () => {
expect(wrapper.find(ChangeDatasourceModal)).toHaveLength(1);
});
it('renders a Modal', () => {
expect(wrapper.find(Modal)).toHaveLength(1);
});
it('fetches datasources', () => {
return new Promise(done => {
inst.onEnterModal();
setTimeout(() => {
expect(fetchMock.calls(DATASOURCES_ENDPOINT)).toHaveLength(1);
fetchMock.reset();
done();
}, 0);
});
it('fetches datasources', async () => {
expect(fetchMock.calls(/superset\/datasources/)).toHaveLength(3);
});
it('changes the datasource', () => {
return new Promise(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);
it('changes the datasource', async () => {
act(() => {
wrapper.find('.datasource-link').at(0).props().onClick(datasourceData);
});
await waitForComponentToPaint(wrapper);
expect(fetchMock.calls(/datasource\/get\/table\/7/)).toHaveLength(1);
});
});

View File

@ -17,66 +17,80 @@
* under the License.
*/
import React from 'react';
import { act } from 'react-dom/test-utils';
import { Modal } from 'react-bootstrap';
import configureStore from 'redux-mock-store';
import { shallow } from 'enzyme';
import { mount } from 'enzyme';
import fetchMock from 'fetch-mock';
import thunk from 'redux-thunk';
import sinon from 'sinon';
import { supersetTheme, ThemeProvider } from '@superset-ui/style';
import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
import DatasourceModal from 'src/datasource/DatasourceModal';
import DatasourceEditor from 'src/datasource/DatasourceEditor';
import mockDatasource from '../../fixtures/mockDatasource';
const props = {
datasource: mockDatasource['7__table'],
const mockStore = configureStore([thunk]);
const store = mockStore({});
const datasource = mockDatasource['7__table'];
const SAVE_ENDPOINT = 'glob:*/api/v1/dataset/7';
const SAVE_PAYLOAD = { new: 'data' };
const mockedProps = {
datasource,
addSuccessToast: () => {},
addDangerToast: () => {},
onChange: () => {},
show: true,
onHide: () => {},
show: true,
onDatasourceSave: sinon.spy(),
};
const SAVE_ENDPOINT = 'glob:*/datasource/save/';
const SAVE_PAYLOAD = { new: 'data' };
async function mountAndWait(props = mockedProps) {
const mounted = mount(<DatasourceModal {...props} />, {
context: { store },
wrappingComponent: ThemeProvider,
wrappingComponentProps: { theme: supersetTheme },
});
await waitForComponentToPaint(mounted);
return mounted;
}
describe('DatasourceModal', () => {
const mockStore = configureStore([thunk]);
const store = mockStore({});
fetchMock.post(SAVE_ENDPOINT, SAVE_PAYLOAD);
const callsP = fetchMock.put(SAVE_ENDPOINT, SAVE_PAYLOAD);
let wrapper;
let el;
let inst;
beforeEach(() => {
el = <DatasourceModal {...props} />;
wrapper = shallow(el, { context: { store } }).dive();
inst = wrapper.instance();
beforeEach(async () => {
wrapper = await mountAndWait();
});
it('is valid', () => {
expect(React.isValidElement(el)).toBe(true);
it('renders', () => {
expect(wrapper.find(DatasourceModal)).toHaveLength(1);
});
it('renders a Modal', () => {
expect(wrapper.find(Modal)).toHaveLength(1);
expect(wrapper.find(Modal)).toHaveLength(2);
});
it('renders a DatasourceEditor', () => {
expect(wrapper.find(DatasourceEditor)).toHaveLength(1);
});
it('saves on confirm', () => {
return new Promise(done => {
inst.onConfirmSave();
setTimeout(() => {
expect(fetchMock.calls(SAVE_ENDPOINT)).toHaveLength(1);
expect(props.onDatasourceSave.getCall(0).args[0]).toEqual(SAVE_PAYLOAD);
fetchMock.reset();
done();
}, 0);
it('saves on confirm', async () => {
act(() => {
wrapper.find('[className="m-r-5"]').props().onClick();
});
await waitForComponentToPaint(wrapper);
act(() => {
const okButton = wrapper.find('[className="btn btn-sm btn-primary"]');
okButton.simulate('click');
});
await waitForComponentToPaint(wrapper);
expect(callsP._calls).toHaveLength(2); /* eslint no-underscore-dangle: 0 */
});
});

View File

@ -1,179 +0,0 @@
/**
* 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 { Alert, 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'];
const CHANGE_WARNING_MSG = t(
'Changing the datasource may break the chart if the chart relies ' +
'on columns or metadata that does not exist in the target datasource',
);
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>
<Alert bsStyle="warning" showIcon>
<strong>{t('Warning!')}</strong> {CHANGE_WARNING_MSG}
</Alert>
<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

@ -0,0 +1,161 @@
/**
* 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, { FunctionComponent, useState, useRef } from 'react';
// @ts-ignore
import { Table } from 'reactable-arc';
import { Alert, 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';
interface ChangeDatasourceModalProps {
addDangerToast: (msg: string) => void;
onChange: (id: number) => void;
onDatasourceSave: (datasource: object, errors?: Array<any>) => {};
onHide: () => void;
show: boolean;
}
const TABLE_COLUMNS = ['name', 'type', 'schema', 'connection', 'creator'];
const TABLE_FILTERABLE = ['rawName', 'type', 'schema', 'connection', 'creator'];
const CHANGE_WARNING_MSG = t(
'Changing the datasource may break the chart if the chart relies ' +
'on columns or metadata that does not exist in the target datasource',
);
const ChangeDatasourceModal: FunctionComponent<ChangeDatasourceModalProps> = ({
addDangerToast,
onChange,
onDatasourceSave,
onHide,
show,
}) => {
const [datasources, setDatasources] = useState<any>(null);
const [filter, setFilter] = useState<any>(undefined);
const [loading, setLoading] = useState(true);
let searchRef = useRef<HTMLInputElement>(null);
const selectDatasource = (datasource: any) => {
SupersetClient.get({
endpoint: `/datasource/get/${datasource.type}/${datasource.id}`,
})
.then(({ json }) => {
onDatasourceSave(json);
onChange(datasource.uid);
})
.catch(response => {
getClientErrorObject(response).then(
({ error, message }: { error: any; message: string }) => {
const errorMessage = error
? error.error || error.statusText || error
: message;
addDangerToast(errorMessage);
},
);
});
onHide();
};
const onEnterModal = () => {
if (searchRef && searchRef.current) {
searchRef.current.focus();
}
if (!datasources) {
SupersetClient.get({
endpoint: '/superset/datasources/',
})
.then(({ json }) => {
const data = json.map((ds: any) => ({
rawName: ds.name,
connection: ds.connection,
schema: ds.schema,
name: (
<a
href="#"
onClick={() => selectDatasource(ds)}
className="datasource-link"
>
{ds.name}
</a>
),
type: ds.type,
}));
setLoading(false);
setDatasources(data);
})
.catch(response => {
setLoading(false);
getClientErrorObject(response).then(({ error }: any) => {
addDangerToast(error.error || error.statusText || error);
});
});
}
};
const setSearchRef = (ref: any) => {
searchRef = ref;
};
const changeSearch = (event: React.ChangeEvent<HTMLInputElement>) => {
setFilter(event.target.value);
};
return (
<Modal show={show} onHide={onHide} onEnter={onEnterModal} bsSize="lg">
<Modal.Header closeButton>
<Modal.Title>{t('Select a datasource')}</Modal.Title>
</Modal.Header>
<Modal.Body>
<Alert bsStyle="warning">
<strong>{t('Warning!')}</strong> {CHANGE_WARNING_MSG}
</Alert>
<div>
<FormControl
inputRef={ref => {
setSearchRef(ref);
}}
type="text"
bsSize="sm"
value={filter}
placeholder={t('Search / Filter')}
// @ts-ignore
onChange={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>
);
};
export default withToasts(ChangeDatasourceModal);

View File

@ -20,6 +20,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { Alert, Badge, Col, Label, Tabs, Tab, Well } from 'react-bootstrap';
import shortid from 'shortid';
import styled from '@superset-ui/style';
import { t } from '@superset-ui/translation';
import { SupersetClient } from '@superset-ui/connection';
import getClientErrorObject from '../utils/getClientErrorObject';
@ -40,7 +41,21 @@ import Field from '../CRUD/Field';
import withToasts from '../messageToasts/enhancers/withToasts';
import './main.less';
const DatasourceContainer = styled.div`
.tab-content {
height: 600px;
overflow: auto;
}
.change-warning {
margin: 16px 10px 0;
color: ${({ theme }) => theme.colors.warning.base};
}
.change-warning .bold {
font-weight: ${({ theme }) => theme.typography.weights.bold};
}
`;
const checkboxGenerator = (d, onChange) => (
<CheckboxControl value={d} onChange={onChange} />
@ -220,8 +235,12 @@ export class DatasourceEditor extends React.PureComponent {
this.state = {
datasource: props.datasource,
errors: [],
isDruid: props.datasource.type === 'druid',
isSqla: props.datasource.type === 'table',
isDruid:
props.datasource.type === 'druid' ||
props.datasource.datasource_type === 'druid',
isSqla:
props.datasource.datasource_type === 'table' ||
props.datasource.type === 'table',
databaseColumns: props.datasource.columns.filter(col => !col.expression),
calculatedColumns: props.datasource.columns.filter(
col => !!col.expression,
@ -290,10 +309,12 @@ export class DatasourceEditor extends React.PureComponent {
const { datasource } = this.state;
// Handle carefully when the schema is empty
const endpoint =
`/datasource/external_metadata/${datasource.type}/${datasource.id}/` +
`/datasource/external_metadata/${
datasource.type || datasource.datasource_type
}/${datasource.id}/` +
`?db_id=${datasource.database.id}` +
`&schema=${datasource.schema || ''}` +
`&table_name=${datasource.datasource_name}`;
`&table_name=${datasource.datasource_name || datasource.table_name}`;
this.setState({ metadataLoading: true });
SupersetClient.get({ endpoint })
@ -645,7 +666,7 @@ export class DatasourceEditor extends React.PureComponent {
render() {
const { datasource, activeTabKey } = this.state;
return (
<div className="Datasource">
<DatasourceContainer>
{this.renderErrors()}
<Tabs
id="table-tabs"
@ -747,7 +768,7 @@ export class DatasourceEditor extends React.PureComponent {
)}
</Tab>
</Tabs>
</div>
</DatasourceContainer>
);
}
}

View File

@ -1,181 +0,0 @@
/**
* 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 { Alert, Button, Modal } from 'react-bootstrap';
import Dialog from 'react-bootstrap-dialog';
import { t } from '@superset-ui/translation';
import { SupersetClient } from '@superset-ui/connection';
import getClientErrorObject from '../utils/getClientErrorObject';
import DatasourceEditor from '../datasource/DatasourceEditor';
import withToasts from '../messageToasts/enhancers/withToasts';
const propTypes = {
onChange: PropTypes.func,
datasource: PropTypes.object.isRequired,
show: PropTypes.bool.isRequired,
onHide: PropTypes.func,
onDatasourceSave: PropTypes.func,
addSuccessToast: PropTypes.func.isRequired,
};
const defaultProps = {
onChange: () => {},
onHide: () => {},
onDatasourceSave: () => {},
show: false,
};
class DatasourceModal extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
errors: [],
datasource: props.datasource,
};
this.setSearchRef = this.setSearchRef.bind(this);
this.onDatasourceChange = this.onDatasourceChange.bind(this);
this.onClickSave = this.onClickSave.bind(this);
this.onConfirmSave = this.onConfirmSave.bind(this);
this.setDialogRef = this.setDialogRef.bind(this);
}
onClickSave() {
this.dialog.show({
title: t('Confirm save'),
bsSize: 'medium',
actions: [Dialog.CancelAction(), Dialog.OKAction(this.onConfirmSave)],
body: this.renderSaveDialog(),
});
}
onConfirmSave() {
SupersetClient.post({
endpoint: '/datasource/save/',
postPayload: {
data: this.state.datasource,
},
})
.then(({ json }) => {
this.props.addSuccessToast(t('The datasource has been saved'));
this.props.onDatasourceSave(json);
this.props.onHide();
})
.catch(response =>
getClientErrorObject(response).then(({ error, statusText }) => {
this.dialog.show({
title: 'Error',
bsSize: 'medium',
bsStyle: 'danger',
actions: [Dialog.DefaultAction('Ok', () => {}, 'btn-danger')],
body: error || statusText || t('An error has occurred'),
});
}),
);
}
onDatasourceChange(datasource, errors) {
this.setState({ datasource, errors });
}
setSearchRef(searchRef) {
this.searchRef = searchRef;
}
setDialogRef(ref) {
this.dialog = ref;
}
renderSaveDialog() {
return (
<div>
<Alert bsStyle="warning" className="pointer" onClick={this.hideAlert}>
<div>
<i className="fa fa-exclamation-triangle" />{' '}
{t(`The data source configuration exposed here
affects all the charts using this datasource.
Be mindful that changing settings
here may affect other charts
in undesirable ways.`)}
</div>
</Alert>
{t('Are you sure you want to save and apply changes?')}
</div>
);
}
render() {
return (
<Modal show={this.props.show} onHide={this.props.onHide} bsSize="lg">
<Modal.Header closeButton>
<Modal.Title>
<div>
<span className="float-left">
{t('Datasource Editor for ')}
<strong>{this.props.datasource.name}</strong>
</span>
</div>
</Modal.Title>
</Modal.Header>
<Modal.Body>
{this.props.show && (
<DatasourceEditor
datasource={this.props.datasource}
onChange={this.onDatasourceChange}
/>
)}
</Modal.Body>
<Modal.Footer>
<span className="float-left">
<Button
bsSize="sm"
bsStyle="default"
target="_blank"
href={this.props.datasource.edit_url}
>
{t('Use Legacy Datasource Editor')}
</Button>
</span>
<span className="float-right">
<Button
bsSize="sm"
bsStyle="primary"
className="m-r-5"
onClick={this.onClickSave}
disabled={this.state.errors.length > 0}
>
{t('Save')}
</Button>
<Button bsSize="sm" onClick={this.props.onHide}>
{t('Cancel')}
</Button>
<Dialog ref={this.setDialogRef} />
</span>
</Modal.Footer>
</Modal>
);
}
}
DatasourceModal.propTypes = propTypes;
DatasourceModal.defaultProps = defaultProps;
export default withToasts(DatasourceModal);

View File

@ -0,0 +1,164 @@
/**
* 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, { FunctionComponent, useState, useRef } from 'react';
import { Alert, Button, Modal } from 'react-bootstrap';
// @ts-ignore
import Dialog from 'react-bootstrap-dialog';
import { t } from '@superset-ui/translation';
import { SupersetClient } from '@superset-ui/connection';
import getClientErrorObject from '../utils/getClientErrorObject';
import DatasourceEditor from './DatasourceEditor';
import withToasts from '../messageToasts/enhancers/withToasts';
interface DatasourceModalProps {
addSuccessToast: (msg: string) => void;
datasource: any;
onChange: () => {};
onDatasourceSave: (datasource: object, errors?: Array<any>) => {};
onHide: () => {};
show: boolean;
}
const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
addSuccessToast,
datasource,
onDatasourceSave,
onHide,
show,
}) => {
const [currentDatasource, setCurrentDatasource] = useState(datasource);
const [errors, setErrors] = useState<any[]>([]);
const dialog = useRef<any>(null);
const onConfirmSave = () => {
SupersetClient.post({
endpoint: '/datasource/save/',
postPayload: {
data: {
...currentDatasource,
type: currentDatasource.type || currentDatasource.datasource_type,
},
},
})
.then(({ json }) => {
addSuccessToast(t('The datasource has been saved'));
onDatasourceSave(json);
onHide();
})
.catch(response =>
getClientErrorObject(response).then(({ error }) => {
dialog.current.show({
title: 'Error',
bsSize: 'medium',
bsStyle: 'danger',
actions: [Dialog.DefaultAction('Ok', () => {}, 'btn-danger')],
body: error || t('An error has occurred'),
});
}),
);
};
const onDatasourceChange = (data: object, err: Array<any>) => {
setCurrentDatasource(data);
setErrors(err);
};
const renderSaveDialog = () => (
<div>
<Alert
bsStyle="warning"
className="pointer"
onClick={dialog.current.hideAlert}
>
<div>
<i className="fa fa-exclamation-triangle" />{' '}
{t(`The data source configuration exposed here
affects all the charts using this datasource.
Be mindful that changing settings
here may affect other charts
in undesirable ways.`)}
</div>
</Alert>
{t('Are you sure you want to save and apply changes?')}
</div>
);
const onClickSave = () => {
dialog.current.show({
title: t('Confirm save'),
bsSize: 'medium',
actions: [Dialog.CancelAction(), Dialog.OKAction(onConfirmSave)],
body: renderSaveDialog(),
});
};
return (
<Modal show={show} onHide={onHide} bsSize="lg">
<Modal.Header closeButton>
<Modal.Title>
<div>
<span className="float-left">
{t('Datasource Editor for ')}
<strong>{currentDatasource.table_name}</strong>
</span>
</div>
</Modal.Title>
</Modal.Header>
<Modal.Body>
{show && (
<DatasourceEditor
datasource={currentDatasource}
onChange={onDatasourceChange}
/>
)}
</Modal.Body>
<Modal.Footer>
<span className="float-left">
<Button
bsSize="sm"
bsStyle="default"
target="_blank"
href={currentDatasource.edit_url || currentDatasource.url}
>
{t('Use Legacy Datasource Editor')}
</Button>
</span>
<span className="float-right">
<Button
bsSize="sm"
bsStyle="primary"
className="m-r-5"
onClick={onClickSave}
disabled={errors.length > 0}
>
{t('Save')}
</Button>
<Button bsSize="sm" onClick={onHide}>
{t('Cancel')}
</Button>
<Dialog ref={dialog} />
</span>
</Modal.Footer>
</Modal>
);
};
export default withToasts(DatasourceModal);

View File

@ -1,33 +0,0 @@
/**
* 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 '../../stylesheets/less/variables.less';
.Datasource .tab-content {
height: 600px;
overflow: auto;
}
.Datasource .change-warning {
margin: 16px 10px 0;
color: @warning;
}
.Datasource .change-warning .bold {
font-weight: @font-weight-bold;
}

View File

@ -27,6 +27,7 @@ import React, {
import rison from 'rison';
import { createFetchRelated, createErrorHandler } from 'src/views/CRUD/utils';
import ConfirmStatusChange from 'src/components/ConfirmStatusChange';
import DatasourceModal from 'src/datasource/DatasourceModal';
import DeleteModal from 'src/components/DeleteModal';
import ListView, { ListViewProps } from 'src/components/ListView/ListView';
import SubMenu, { SubMenuProps } from 'src/components/Menu/SubMenu';
@ -146,6 +147,10 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
const [datasetCurrentlyDeleting, setDatasetCurrentlyDeleting] = useState<
(Dataset & { chart_count: number; dashboard_count: number }) | null
>(null);
const [
datasetCurrentlyEditing,
setDatasetCurrentlyEditing,
] = useState<Dataset | null>(null);
const [datasets, setDatasets] = useState<any[]>([]);
const [
lastFetchDataConfig,
@ -252,8 +257,19 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
const initialSort = [{ id: 'changed_on_delta_humanized', desc: true }];
const handleDatasetEdit = ({ id }: { id: number }) => {
window.location.assign(`/tablemodelview/edit/${id}`);
const openDatasetEditModal = ({ id }: Dataset) => {
SupersetClient.get({
endpoint: `/api/v1/dataset/${id}`,
})
.then(({ json = {} }) => {
const owners = json.result.owners.map((owner: any) => owner.id);
setDatasetCurrentlyEditing({ ...json.result, owners });
})
.catch(() => {
addDangerToast(
t('An error occurred while fetching dataset related data'),
);
});
};
const openDatasetDeleteModal = (dataset: Dataset) =>
@ -395,8 +411,8 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
disableSortBy: true,
},
{
Cell: ({ row: { original } }: any) => {
const handleEdit = () => handleDatasetEdit(original);
Cell: ({ row: { state, original } }: any) => {
const handleEdit = () => openDatasetEditModal(original);
const handleDelete = () => openDatasetDeleteModal(original);
if (!canEdit && !canDelete) {
return null;
@ -500,6 +516,8 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
setDatasetCurrentlyDeleting(null);
};
const closeDatasetEditModal = () => setDatasetCurrentlyEditing(null);
const fetchData = useCallback(
({ pageIndex, pageSize, sortBy, filters }: FetchDataConfig) => {
// set loading state, cache the last config for fetching data in this component.
@ -583,6 +601,12 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
);
};
const handleUpdateDataset = () => {
if (lastFetchDataConfig) {
fetchData(lastFetchDataConfig);
}
};
return (
<>
<SubMenu {...menuData} />
@ -627,54 +651,82 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
: [];
return (
<ListView
className="dataset-list-view"
columns={columns}
data={datasets}
count={datasetCount}
pageSize={PAGE_SIZE}
fetchData={fetchData}
filters={filterTypes}
loading={loading}
initialSort={initialSort}
bulkActions={bulkActions}
bulkSelectEnabled={bulkSelectEnabled}
disableBulkSelect={() => setBulkSelectEnabled(false)}
renderBulkSelectCopy={selected => {
const { virtualCount, physicalCount } = selected.reduce(
(acc, e) => {
if (e.original.kind === 'physical') acc.physicalCount += 1;
else if (e.original.kind === 'virtual')
acc.virtualCount += 1;
return acc;
},
{ virtualCount: 0, physicalCount: 0 },
);
if (!selected.length) {
return t('0 Selected');
} else if (virtualCount && !physicalCount) {
return t(
'%s Selected (Virtual)',
selected.length,
virtualCount,
<>
{datasetCurrentlyDeleting && (
<DeleteModal
description={t(
`The dataset ${datasetCurrentlyDeleting.table_name} is linked to
${datasetCurrentlyDeleting.chart_count} charts that appear on
${datasetCurrentlyDeleting.dashboard_count} dashboards.
Are you sure you want to continue? Deleting the dataset will break
those objects.`,
)}
onConfirm={() =>
handleDatasetDelete(datasetCurrentlyDeleting)
}
onHide={closeDatasetDeleteModal}
open
title={t('Delete Dataset?')}
/>
)}
{datasetCurrentlyEditing && (
<DatasourceModal
datasource={datasetCurrentlyEditing}
onDatasourceSave={handleUpdateDataset}
onHide={closeDatasetEditModal}
show
/>
)}
<ListView
className="dataset-list-view"
columns={columns}
data={datasets}
count={datasetCount}
pageSize={PAGE_SIZE}
fetchData={fetchData}
filters={filterTypes}
loading={loading}
initialSort={initialSort}
bulkActions={bulkActions}
bulkSelectEnabled={bulkSelectEnabled}
disableBulkSelect={() => setBulkSelectEnabled(false)}
renderBulkSelectCopy={selected => {
const { virtualCount, physicalCount } = selected.reduce(
(acc, e) => {
if (e.original.kind === 'physical')
acc.physicalCount += 1;
else if (e.original.kind === 'virtual')
acc.virtualCount += 1;
return acc;
},
{ virtualCount: 0, physicalCount: 0 },
);
} else if (physicalCount && !virtualCount) {
if (!selected.length) {
return t('0 Selected');
} else if (virtualCount && !physicalCount) {
return t(
'%s Selected (Virtual)',
selected.length,
virtualCount,
);
} else if (physicalCount && !virtualCount) {
return t(
'%s Selected (Physical)',
selected.length,
physicalCount,
);
}
return t(
'%s Selected (Physical)',
'%s Selected (%s Physical, %s Virtual)',
selected.length,
physicalCount,
virtualCount,
);
}
return t(
'%s Selected (%s Physical, %s Virtual)',
selected.length,
physicalCount,
virtualCount,
);
}}
/>
}}
/>
</>
);
}}
</ConfirmStatusChange>

View File

@ -540,6 +540,10 @@ class DruidDatasource(Model, BaseDatasource):
def name(self) -> str:
return self.datasource_name
@property
def datasource_type(self) -> str:
return self.type
@property
def schema(self) -> Optional[str]:
ds_name = self.datasource_name or ""

View File

@ -493,6 +493,14 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
def datasource_name(self) -> str:
return self.table_name
@property
def datasource_type(self) -> str:
return self.type
@property
def database_name(self) -> str:
return self.database.name
@classmethod
def get_datasource_by_name(
cls,

View File

@ -100,6 +100,7 @@ class DatasetRestApi(BaseSupersetModelRestApi):
"database.database_name",
]
show_columns = [
"id",
"database.database_name",
"database.id",
"table_name",
@ -120,6 +121,8 @@ class DatasetRestApi(BaseSupersetModelRestApi):
"owners.last_name",
"columns",
"metrics",
"datasource_type",
"url",
]
add_model_schema = DatasetPostSchema()
edit_model_schema = DatasetPutSchema()