From 39fad8575c62570df3b25b8ddeced5bc481cc6f1 Mon Sep 17 00:00:00 2001 From: Lily Kuang Date: Tue, 28 Jul 2020 15:53:20 -0700 Subject: [PATCH] feat: update dataset editor modal (#10347) --- .../datasource/ChangeDatasourceModal_spec.jsx | 71 ++++--- .../datasource/DatasourceModal_spec.jsx | 66 ++++--- .../src/datasource/ChangeDatasourceModal.jsx | 179 ----------------- .../src/datasource/ChangeDatasourceModal.tsx | 161 ++++++++++++++++ .../src/datasource/DatasourceEditor.jsx | 35 +++- .../src/datasource/DatasourceModal.jsx | 181 ------------------ .../src/datasource/DatasourceModal.tsx | 164 ++++++++++++++++ superset-frontend/src/datasource/main.less | 33 ---- .../src/views/CRUD/dataset/DatasetList.tsx | 146 +++++++++----- superset/connectors/druid/models.py | 4 + superset/connectors/sqla/models.py | 8 + superset/datasets/api.py | 3 + 12 files changed, 541 insertions(+), 510 deletions(-) delete mode 100644 superset-frontend/src/datasource/ChangeDatasourceModal.jsx create mode 100644 superset-frontend/src/datasource/ChangeDatasourceModal.tsx delete mode 100644 superset-frontend/src/datasource/DatasourceModal.jsx create mode 100644 superset-frontend/src/datasource/DatasourceModal.tsx delete mode 100644 superset-frontend/src/datasource/main.less diff --git a/superset-frontend/spec/javascripts/datasource/ChangeDatasourceModal_spec.jsx b/superset-frontend/spec/javascripts/datasource/ChangeDatasourceModal_spec.jsx index 3b6e9d5508..7392015736 100644 --- a/superset-frontend/spec/javascripts/datasource/ChangeDatasourceModal_spec.jsx +++ b/superset-frontend/spec/javascripts/datasource/ChangeDatasourceModal_spec.jsx @@ -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(, { + 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 = ; - 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); }); }); diff --git a/superset-frontend/spec/javascripts/datasource/DatasourceModal_spec.jsx b/superset-frontend/spec/javascripts/datasource/DatasourceModal_spec.jsx index fcd18abd84..e1b3882c65 100644 --- a/superset-frontend/spec/javascripts/datasource/DatasourceModal_spec.jsx +++ b/superset-frontend/spec/javascripts/datasource/DatasourceModal_spec.jsx @@ -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(, { + 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 = ; - 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 */ }); }); diff --git a/superset-frontend/src/datasource/ChangeDatasourceModal.jsx b/superset-frontend/src/datasource/ChangeDatasourceModal.jsx deleted file mode 100644 index bd74d988aa..0000000000 --- a/superset-frontend/src/datasource/ChangeDatasourceModal.jsx +++ /dev/null @@ -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: ( - - {ds.name} - - ), - 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 ( - - - {t('Select a datasource')} - - - - {t('Warning!')} {CHANGE_WARNING_MSG} - -
- { - this.setSearchRef(ref); - }} - type="text" - bsSize="sm" - value={filter} - placeholder={t('Search / Filter')} - onChange={this.changeSearch} - /> -
- {loading && } - {datasources && ( - - )} - - - ); - } -} - -ChangeDatasourceModal.propTypes = propTypes; -ChangeDatasourceModal.defaultProps = defaultProps; - -export default withToasts(ChangeDatasourceModal); diff --git a/superset-frontend/src/datasource/ChangeDatasourceModal.tsx b/superset-frontend/src/datasource/ChangeDatasourceModal.tsx new file mode 100644 index 0000000000..0b56bd239d --- /dev/null +++ b/superset-frontend/src/datasource/ChangeDatasourceModal.tsx @@ -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) => {}; + 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 = ({ + addDangerToast, + onChange, + onDatasourceSave, + onHide, + show, +}) => { + const [datasources, setDatasources] = useState(null); + const [filter, setFilter] = useState(undefined); + const [loading, setLoading] = useState(true); + let searchRef = useRef(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: ( + selectDatasource(ds)} + className="datasource-link" + > + {ds.name} + + ), + 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) => { + setFilter(event.target.value); + }; + + return ( + + + {t('Select a datasource')} + + + + {t('Warning!')} {CHANGE_WARNING_MSG} + +
+ { + setSearchRef(ref); + }} + type="text" + bsSize="sm" + value={filter} + placeholder={t('Search / Filter')} + // @ts-ignore + onChange={changeSearch} + /> +
+ {loading && } + {datasources && ( +
+ )} + + + ); +}; + +export default withToasts(ChangeDatasourceModal); diff --git a/superset-frontend/src/datasource/DatasourceEditor.jsx b/superset-frontend/src/datasource/DatasourceEditor.jsx index 114a36d4dc..cee9daab45 100644 --- a/superset-frontend/src/datasource/DatasourceEditor.jsx +++ b/superset-frontend/src/datasource/DatasourceEditor.jsx @@ -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) => ( @@ -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 ( -
+ {this.renderErrors()} -
+ ); } } diff --git a/superset-frontend/src/datasource/DatasourceModal.jsx b/superset-frontend/src/datasource/DatasourceModal.jsx deleted file mode 100644 index 5959716aa3..0000000000 --- a/superset-frontend/src/datasource/DatasourceModal.jsx +++ /dev/null @@ -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 ( -
- -
- {' '} - {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.`)} -
-
- {t('Are you sure you want to save and apply changes?')} -
- ); - } - - render() { - return ( - - - -
- - {t('Datasource Editor for ')} - {this.props.datasource.name} - -
-
-
- - {this.props.show && ( - - )} - - - - - - - - - - - - -
- ); - } -} - -DatasourceModal.propTypes = propTypes; -DatasourceModal.defaultProps = defaultProps; - -export default withToasts(DatasourceModal); diff --git a/superset-frontend/src/datasource/DatasourceModal.tsx b/superset-frontend/src/datasource/DatasourceModal.tsx new file mode 100644 index 0000000000..c42689ec6e --- /dev/null +++ b/superset-frontend/src/datasource/DatasourceModal.tsx @@ -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) => {}; + onHide: () => {}; + show: boolean; +} + +const DatasourceModal: FunctionComponent = ({ + addSuccessToast, + datasource, + onDatasourceSave, + onHide, + show, +}) => { + const [currentDatasource, setCurrentDatasource] = useState(datasource); + const [errors, setErrors] = useState([]); + const dialog = useRef(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) => { + setCurrentDatasource(data); + setErrors(err); + }; + + const renderSaveDialog = () => ( +
+ +
+ {' '} + {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.`)} +
+
+ {t('Are you sure you want to save and apply changes?')} +
+ ); + + const onClickSave = () => { + dialog.current.show({ + title: t('Confirm save'), + bsSize: 'medium', + actions: [Dialog.CancelAction(), Dialog.OKAction(onConfirmSave)], + body: renderSaveDialog(), + }); + }; + + return ( + + + +
+ + {t('Datasource Editor for ')} + {currentDatasource.table_name} + +
+
+
+ + {show && ( + + )} + + + + + + + + + + + + +
+ ); +}; + +export default withToasts(DatasourceModal); diff --git a/superset-frontend/src/datasource/main.less b/superset-frontend/src/datasource/main.less deleted file mode 100644 index 955ced4c73..0000000000 --- a/superset-frontend/src/datasource/main.less +++ /dev/null @@ -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; -} diff --git a/superset-frontend/src/views/CRUD/dataset/DatasetList.tsx b/superset-frontend/src/views/CRUD/dataset/DatasetList.tsx index 4e3dda6d7f..2cc47d3725 100644 --- a/superset-frontend/src/views/CRUD/dataset/DatasetList.tsx +++ b/superset-frontend/src/views/CRUD/dataset/DatasetList.tsx @@ -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 = ({ const [datasetCurrentlyDeleting, setDatasetCurrentlyDeleting] = useState< (Dataset & { chart_count: number; dashboard_count: number }) | null >(null); + const [ + datasetCurrentlyEditing, + setDatasetCurrentlyEditing, + ] = useState(null); const [datasets, setDatasets] = useState([]); const [ lastFetchDataConfig, @@ -252,8 +257,19 @@ const DatasetList: FunctionComponent = ({ 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 = ({ 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 = ({ 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 = ({ ); }; + const handleUpdateDataset = () => { + if (lastFetchDataConfig) { + fetchData(lastFetchDataConfig); + } + }; + return ( <> @@ -627,54 +651,82 @@ const DatasetList: FunctionComponent = ({ : []; return ( - 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 && ( + + handleDatasetDelete(datasetCurrentlyDeleting) + } + onHide={closeDatasetDeleteModal} + open + title={t('Delete Dataset?')} + /> + )} + {datasetCurrentlyEditing && ( + + )} + 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, - ); - }} - /> + }} + /> + ); }} diff --git a/superset/connectors/druid/models.py b/superset/connectors/druid/models.py index ffd178d7c0..4611489072 100644 --- a/superset/connectors/druid/models.py +++ b/superset/connectors/druid/models.py @@ -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 "" diff --git a/superset/connectors/sqla/models.py b/superset/connectors/sqla/models.py index 249409681b..530a2e10ac 100644 --- a/superset/connectors/sqla/models.py +++ b/superset/connectors/sqla/models.py @@ -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, diff --git a/superset/datasets/api.py b/superset/datasets/api.py index eee5e33947..0d7973b4a7 100644 --- a/superset/datasets/api.py +++ b/superset/datasets/api.py @@ -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()