diff --git a/superset-frontend/spec/javascripts/datasource/ChangeDatasourceModal_spec.jsx b/superset-frontend/spec/javascripts/datasource/ChangeDatasourceModal_spec.jsx index c058851a05..b803c4641c 100644 --- a/superset-frontend/spec/javascripts/datasource/ChangeDatasourceModal_spec.jsx +++ b/superset-frontend/spec/javascripts/datasource/ChangeDatasourceModal_spec.jsx @@ -21,9 +21,9 @@ import { mount } from 'enzyme'; import configureStore from 'redux-mock-store'; import fetchMock from 'fetch-mock'; import thunk from 'redux-thunk'; +import { act } from 'react-dom/test-utils'; import sinon from 'sinon'; import { supersetTheme, ThemeProvider } from '@superset-ui/core'; -import { act } from 'react-dom/test-utils'; import Modal from 'src/common/components/Modal'; import ChangeDatasourceModal from 'src/datasource/ChangeDatasourceModal'; import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint'; @@ -47,11 +47,12 @@ const datasourceData = { uid: datasource.id, }; -const DATASOURCES_ENDPOINT = 'glob:*/superset/datasources/'; +const DATASOURCES_ENDPOINT = + 'glob:*/api/v1/dataset/?q=(order_column:changed_on_delta_humanized,order_direction:asc,page:0,page_size:20)'; const DATASOURCE_ENDPOINT = `glob:*/datasource/get/${datasourceData.type}/${datasourceData.id}`; const DATASOURCE_PAYLOAD = { new: 'data' }; -fetchMock.get(DATASOURCES_ENDPOINT, [mockDatasource['7__table']]); +fetchMock.get(DATASOURCES_ENDPOINT, { result: [mockDatasource['7__table']] }); fetchMock.get(DATASOURCE_ENDPOINT, DATASOURCE_PAYLOAD); async function mountAndWait(props = mockedProps) { @@ -80,14 +81,29 @@ describe('ChangeDatasourceModal', () => { }); it('fetches datasources', async () => { - expect(fetchMock.calls(/superset\/datasources/)).toHaveLength(3); + expect(fetchMock.calls(/api\/v1\/dataset/)).toHaveLength(6); + }); + + it('renders confirmation message', async () => { + act(() => { + wrapper.find('.datasource-link').at(0).props().onClick(); + }); + await waitForComponentToPaint(wrapper); + + expect(wrapper.find('.proceed-btn')).toExist(); }); it('changes the datasource', async () => { act(() => { - wrapper.find('.datasource-link').at(0).props().onClick(datasourceData); + wrapper.find('.datasource-link').at(0).props().onClick(); }); await waitForComponentToPaint(wrapper); + + act(() => { + wrapper.find('.proceed-btn').at(0).props().onClick(datasourceData); + }); + await waitForComponentToPaint(wrapper); + expect(fetchMock.calls(/datasource\/get\/table\/7/)).toHaveLength(1); }); }); diff --git a/superset-frontend/src/datasource/ChangeDatasourceModal.tsx b/superset-frontend/src/datasource/ChangeDatasourceModal.tsx index 81b9b8cb8c..77b3ad4627 100644 --- a/superset-frontend/src/datasource/ChangeDatasourceModal.tsx +++ b/superset-frontend/src/datasource/ChangeDatasourceModal.tsx @@ -20,25 +20,53 @@ import React, { FunctionComponent, useState, useRef, - useMemo, useEffect, + useCallback, } from 'react'; import { Alert, FormControl, FormControlProps } from 'react-bootstrap'; -import { SupersetClient, t } from '@superset-ui/core'; +import { SupersetClient, t, styled } from '@superset-ui/core'; import TableView from 'src/components/TableView'; -import Modal from 'src/common/components/Modal'; +import StyledModal from 'src/common/components/Modal'; +import Button from 'src/components/Button'; +import { useListViewResource } from 'src/views/CRUD/hooks'; +import Dataset from 'src/types/Dataset'; +import { useDebouncedEffect } from 'src/explore/exploreUtils'; import { getClientErrorObject } from '../utils/getClientErrorObject'; import Loading from '../components/Loading'; import withToasts from '../messageToasts/enhancers/withToasts'; +const CONFIRM_WARNING_MESSAGE = t( + 'Warning! Changing the dataset may break the chart if the metadata (columns/metrics) does not exist in the target dataset', +); + +interface Datasource { + type: string; + id: number; + uid: string; +} + interface ChangeDatasourceModalProps { addDangerToast: (msg: string) => void; - onChange: (id: number) => void; + addSuccessToast: (msg: string) => void; + onChange: (uid: string) => void; onDatasourceSave: (datasource: object, errors?: Array) => {}; onHide: () => void; show: boolean; } +const ConfirmModalStyled = styled.div` + .btn-container { + display: flex; + justify-content: flex-end; + padding: 0px 15px; + margin: 10px 0 0 0; + } + + .confirm-modal-container { + margin: 9px; + } +`; + const TABLE_COLUMNS = [ 'name', 'type', @@ -47,86 +75,78 @@ const TABLE_COLUMNS = [ 'creator', ].map(col => ({ accessor: col, Header: col })); -const TABLE_FILTERABLE = ['rawName', 'type', 'schema', 'connection', 'creator']; const CHANGE_WARNING_MSG = t( 'Changing the dataset may break the chart if the chart relies ' + 'on columns or metadata that does not exist in the target dataset', ); +const emptyRequest = { + pageIndex: 0, + pageSize: 20, + filters: [], + sortBy: [{ id: 'changed_on_delta_humanized' }], +}; + const ChangeDatasourceModal: FunctionComponent = ({ addDangerToast, + addSuccessToast, onChange, onDatasourceSave, onHide, show, }) => { - const [datasources, setDatasources] = useState(null); const [filter, setFilter] = useState(undefined); - const [loading, setLoading] = useState(true); + const [confirmChange, setConfirmChange] = useState(false); + const [confirmedDataset, setConfirmedDataset] = useState(); let searchRef = useRef(null); - useEffect(() => { - 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 { + state: { loading, resourceCollection }, + fetchData, + } = useListViewResource('dataset', t('dataset'), addDangerToast); - const onEnterModal = () => { + const selectDatasource = useCallback((datasource: Datasource) => { + setConfirmChange(true); + setConfirmedDataset(datasource); + }, []); + + useDebouncedEffect(() => { + if (filter) { + fetchData({ + ...emptyRequest, + filters: [ + { + id: 'table_name', + operator: 'ct', + value: filter, + }, + ], + }); + } + }, 1000); + + useEffect(() => { + const onEnterModal = async () => { 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); - }); - }); - } + + // Fetch initial datasets for tableview + await fetchData(emptyRequest); }; if (show) { onEnterModal(); } - }, [addDangerToast, datasources, onChange, onDatasourceSave, onHide, show]); + }, [ + addDangerToast, + fetchData, + onChange, + onDatasourceSave, + onHide, + selectDatasource, + show, + ]); const setSearchRef = (ref: any) => { searchRef = ref; @@ -135,21 +155,58 @@ const ChangeDatasourceModal: FunctionComponent = ({ const changeSearch = ( event: React.FormEvent, ) => { - setFilter((event.currentTarget?.value as string) ?? ''); + const searchValue = (event.currentTarget?.value as string) ?? ''; + setFilter(searchValue); }; - const data = useMemo( - () => - filter && datasources - ? datasources.filter((datasource: any) => - TABLE_FILTERABLE.some(field => datasource[field]?.includes(filter)), - ) - : datasources, - [datasources, filter], - ); + const handleChangeConfirm = () => { + SupersetClient.get({ + endpoint: `/datasource/get/${confirmedDataset?.type}/${confirmedDataset?.id}`, + }) + .then(({ json }) => { + onDatasourceSave(json); + onChange(`${confirmedDataset?.id}__table`); + }) + .catch(response => { + getClientErrorObject(response).then( + ({ error, message }: { error: any; message: string }) => { + const errorMessage = error + ? error.error || error.statusText || error + : message; + addDangerToast(errorMessage); + }, + ); + }); + onHide(); + addSuccessToast('Successfully changed datasource!'); + }; + + const handlerCancelConfirm = () => { + setConfirmChange(false); + }; + + const renderTableView = () => { + const data = resourceCollection.map((ds: any) => ({ + rawName: ds.table_name, + connection: ds.database.database_name, + schema: ds.schema, + name: ( + selectDatasource({ type: 'table', ...ds })} + className="datasource-link" + > + {ds.table_name} + + ), + type: ds.kind, + })); + + return data; + }; return ( - = ({ hideFooter > <> - - {t('Warning!')} {CHANGE_WARNING_MSG} - -
- { - setSearchRef(ref); - }} - type="text" - bsSize="sm" - value={filter} - placeholder={t('Search / Filter')} - onChange={changeSearch} - /> -
- {loading && } - {datasources && ( - + {!confirmChange && ( + <> + + {t('Warning!')} {CHANGE_WARNING_MSG} + +
+ { + setSearchRef(ref); + }} + type="text" + bsSize="sm" + value={filter} + placeholder={t('Search / Filter')} + onChange={changeSearch} + /> +
+ {loading && } + {!loading && ( + + )} + + )} + {confirmChange && ( + +
+ {CONFIRM_WARNING_MESSAGE} +
+ + +
+
+
)} -
+ ); }; diff --git a/superset-frontend/src/explore/exploreUtils.js b/superset-frontend/src/explore/exploreUtils.js index e69ce0bf71..3b70733820 100644 --- a/superset-frontend/src/explore/exploreUtils.js +++ b/superset-frontend/src/explore/exploreUtils.js @@ -16,6 +16,8 @@ * specific language governing permissions and limitations * under the License. */ + +import { useCallback, useEffect } from 'react'; /* eslint camelcase: 0 */ import URI from 'urijs'; import { @@ -284,3 +286,17 @@ export const exploreChart = formData => { }); postForm(url, formData); }; + +export const useDebouncedEffect = (effect, delay) => { + const callback = useCallback(effect, [effect]); + + useEffect(() => { + const handler = setTimeout(() => { + callback(); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [callback, delay]); +}; diff --git a/superset-frontend/src/types/Dataset.ts b/superset-frontend/src/types/Dataset.ts new file mode 100644 index 0000000000..7d69932f6f --- /dev/null +++ b/superset-frontend/src/types/Dataset.ts @@ -0,0 +1,36 @@ +/** + * 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 Owner from './Owner'; + +export default interface Dataset { + changed_by_name: string; + changed_by_url: string; + changed_by: string; + changed_on_delta_humanized: string; + database: { + id: string; + database_name: string; + }; + kind: string; + explore_url: string; + id: number; + owners: Array; + schema: string; + table_name: string; +}