fix: Explore "Change Dataset" UX Enhancements (#12006)

This commit is contained in:
Hugh A. Miles II 2020-12-14 10:17:20 -08:00 committed by GitHub
parent d5b16bcd85
commit 3d56f58ef5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 246 additions and 100 deletions

View File

@ -21,9 +21,9 @@ import { mount } from 'enzyme';
import configureStore from 'redux-mock-store'; import configureStore from 'redux-mock-store';
import fetchMock from 'fetch-mock'; import fetchMock from 'fetch-mock';
import thunk from 'redux-thunk'; import thunk from 'redux-thunk';
import { act } from 'react-dom/test-utils';
import sinon from 'sinon'; import sinon from 'sinon';
import { supersetTheme, ThemeProvider } from '@superset-ui/core'; import { supersetTheme, ThemeProvider } from '@superset-ui/core';
import { act } from 'react-dom/test-utils';
import Modal from 'src/common/components/Modal'; import Modal from 'src/common/components/Modal';
import ChangeDatasourceModal from 'src/datasource/ChangeDatasourceModal'; import ChangeDatasourceModal from 'src/datasource/ChangeDatasourceModal';
import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint'; import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
@ -47,11 +47,12 @@ const datasourceData = {
uid: datasource.id, 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_ENDPOINT = `glob:*/datasource/get/${datasourceData.type}/${datasourceData.id}`;
const DATASOURCE_PAYLOAD = { new: 'data' }; 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); fetchMock.get(DATASOURCE_ENDPOINT, DATASOURCE_PAYLOAD);
async function mountAndWait(props = mockedProps) { async function mountAndWait(props = mockedProps) {
@ -80,14 +81,29 @@ describe('ChangeDatasourceModal', () => {
}); });
it('fetches datasources', async () => { 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 () => { it('changes the datasource', async () => {
act(() => { act(() => {
wrapper.find('.datasource-link').at(0).props().onClick(datasourceData); wrapper.find('.datasource-link').at(0).props().onClick();
}); });
await waitForComponentToPaint(wrapper); 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); expect(fetchMock.calls(/datasource\/get\/table\/7/)).toHaveLength(1);
}); });
}); });

View File

@ -20,25 +20,53 @@ import React, {
FunctionComponent, FunctionComponent,
useState, useState,
useRef, useRef,
useMemo,
useEffect, useEffect,
useCallback,
} from 'react'; } from 'react';
import { Alert, FormControl, FormControlProps } from 'react-bootstrap'; 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 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 { getClientErrorObject } from '../utils/getClientErrorObject';
import Loading from '../components/Loading'; import Loading from '../components/Loading';
import withToasts from '../messageToasts/enhancers/withToasts'; 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 { interface ChangeDatasourceModalProps {
addDangerToast: (msg: string) => void; addDangerToast: (msg: string) => void;
onChange: (id: number) => void; addSuccessToast: (msg: string) => void;
onChange: (uid: string) => void;
onDatasourceSave: (datasource: object, errors?: Array<any>) => {}; onDatasourceSave: (datasource: object, errors?: Array<any>) => {};
onHide: () => void; onHide: () => void;
show: boolean; 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 = [ const TABLE_COLUMNS = [
'name', 'name',
'type', 'type',
@ -47,86 +75,78 @@ const TABLE_COLUMNS = [
'creator', 'creator',
].map(col => ({ accessor: col, Header: col })); ].map(col => ({ accessor: col, Header: col }));
const TABLE_FILTERABLE = ['rawName', 'type', 'schema', 'connection', 'creator'];
const CHANGE_WARNING_MSG = t( const CHANGE_WARNING_MSG = t(
'Changing the dataset may break the chart if the chart relies ' + 'Changing the dataset may break the chart if the chart relies ' +
'on columns or metadata that does not exist in the target dataset', '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<ChangeDatasourceModalProps> = ({ const ChangeDatasourceModal: FunctionComponent<ChangeDatasourceModalProps> = ({
addDangerToast, addDangerToast,
addSuccessToast,
onChange, onChange,
onDatasourceSave, onDatasourceSave,
onHide, onHide,
show, show,
}) => { }) => {
const [datasources, setDatasources] = useState<any>(null);
const [filter, setFilter] = useState<any>(undefined); const [filter, setFilter] = useState<any>(undefined);
const [loading, setLoading] = useState(true); const [confirmChange, setConfirmChange] = useState(false);
const [confirmedDataset, setConfirmedDataset] = useState<Datasource>();
let searchRef = useRef<HTMLInputElement>(null); let searchRef = useRef<HTMLInputElement>(null);
useEffect(() => { const {
const selectDatasource = (datasource: any) => { state: { loading, resourceCollection },
SupersetClient.get({ fetchData,
endpoint: `/datasource/get/${datasource.type}/${datasource.id}`, } = useListViewResource<Dataset>('dataset', t('dataset'), addDangerToast);
})
.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 = () => { 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) { if (searchRef && searchRef.current) {
searchRef.current.focus(); searchRef.current.focus();
} }
if (!datasources) {
SupersetClient.get({ // Fetch initial datasets for tableview
endpoint: '/superset/datasources/', await fetchData(emptyRequest);
})
.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);
});
});
}
}; };
if (show) { if (show) {
onEnterModal(); onEnterModal();
} }
}, [addDangerToast, datasources, onChange, onDatasourceSave, onHide, show]); }, [
addDangerToast,
fetchData,
onChange,
onDatasourceSave,
onHide,
selectDatasource,
show,
]);
const setSearchRef = (ref: any) => { const setSearchRef = (ref: any) => {
searchRef = ref; searchRef = ref;
@ -135,21 +155,58 @@ const ChangeDatasourceModal: FunctionComponent<ChangeDatasourceModalProps> = ({
const changeSearch = ( const changeSearch = (
event: React.FormEvent<FormControl & FormControlProps>, event: React.FormEvent<FormControl & FormControlProps>,
) => { ) => {
setFilter((event.currentTarget?.value as string) ?? ''); const searchValue = (event.currentTarget?.value as string) ?? '';
setFilter(searchValue);
}; };
const data = useMemo( const handleChangeConfirm = () => {
() => SupersetClient.get({
filter && datasources endpoint: `/datasource/get/${confirmedDataset?.type}/${confirmedDataset?.id}`,
? datasources.filter((datasource: any) => })
TABLE_FILTERABLE.some(field => datasource[field]?.includes(filter)), .then(({ json }) => {
) onDatasourceSave(json);
: datasources, onChange(`${confirmedDataset?.id}__table`);
[datasources, filter], })
); .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: (
<a
href="#"
onClick={() => selectDatasource({ type: 'table', ...ds })}
className="datasource-link"
>
{ds.table_name}
</a>
),
type: ds.kind,
}));
return data;
};
return ( return (
<Modal <StyledModal
show={show} show={show}
onHide={onHide} onHide={onHide}
responsive responsive
@ -157,32 +214,53 @@ const ChangeDatasourceModal: FunctionComponent<ChangeDatasourceModalProps> = ({
hideFooter hideFooter
> >
<> <>
<Alert bsStyle="warning"> {!confirmChange && (
<strong>{t('Warning!')}</strong> {CHANGE_WARNING_MSG} <>
</Alert> <Alert bsStyle="warning">
<div> <strong>{t('Warning!')}</strong> {CHANGE_WARNING_MSG}
<FormControl </Alert>
inputRef={ref => { <div>
setSearchRef(ref); <FormControl
}} inputRef={ref => {
type="text" setSearchRef(ref);
bsSize="sm" }}
value={filter} type="text"
placeholder={t('Search / Filter')} bsSize="sm"
onChange={changeSearch} value={filter}
/> placeholder={t('Search / Filter')}
</div> onChange={changeSearch}
{loading && <Loading />} />
{datasources && ( </div>
<TableView {loading && <Loading />}
columns={TABLE_COLUMNS} {!loading && (
data={data} <TableView
pageSize={20} columns={TABLE_COLUMNS}
className="table-condensed" data={renderTableView()}
/> pageSize={20}
className="table-condensed"
/>
)}
</>
)}
{confirmChange && (
<ConfirmModalStyled>
<div className="confirm-modal-container">
{CONFIRM_WARNING_MESSAGE}
<div className="btn-container">
<Button onClick={handlerCancelConfirm}>Cancel</Button>
<Button
className="proceed-btn"
buttonStyle="primary"
onClick={handleChangeConfirm}
>
Proceed
</Button>
</div>
</div>
</ConfirmModalStyled>
)} )}
</> </>
</Modal> </StyledModal>
); );
}; };

View File

@ -16,6 +16,8 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { useCallback, useEffect } from 'react';
/* eslint camelcase: 0 */ /* eslint camelcase: 0 */
import URI from 'urijs'; import URI from 'urijs';
import { import {
@ -284,3 +286,17 @@ export const exploreChart = formData => {
}); });
postForm(url, 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]);
};

View File

@ -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<Owner>;
schema: string;
table_name: string;
}