feat(explore): Frontend implementation of dataset creation from infobox (#19855)

* Frontend implementation of create dataset from infobox

* Fixed sl_dataset type

* Fix test

* Fixed sl_dataset type (forgot to save)

* RTL testing

* Adjusted styling/text on infobox and save dataset modal

* Appease lint

* Make infobox invisible and fix tests

* Remove unnecessary placeholder

* Move types to sql lab

* Moved logic into save dataset modal

* Change DatasourceMeta type to Dataset

* Add ExploreDatasource union type to save dataset modal

* Get user info from redux inside save dataset modal

* Addressed comments

* Adjusting to new query type

* Fixed save dataset in explore and union type

* Added testing

* Defined d for queries

* Remove dataset from SaveDatasetModal

* Clarify useSelector parameter

* Fix dndControls union type

* Fix shared-controls union type

* Fix controlPanel union type

* Move ExploreRootState to explore type file

* Remove unnecessary testing playground

* Move datasource type check in DatasourcePanel to a function

* Make all sqllab Query imports reference @superset-ui/core Query type

* Deconstruct query props in ResultSet

* Fix union type in /legacy-plugin-chart-heatmap/src/controlPanel

* Change SaveDatasetModal tests to RTL

* Cleaned datasourceTypeCheck

* Fix infobox styling

* Fix SaveDatasetModal test

* Fix query fixture in sqllab and Query type in SaveDatasetModal test

* Fix Query type and make test query fixture

* Added columns to Query type, separated results property, created QueryResponse union type, and fixed all types affected

* Fixed a couple missed broken types

* Added ExploreDatasource to SqlLab type file

* Removed unneeded Query import from DatasourcePanel

* Address PR comments

* Fix columnChoices

* Fix all incorrect column property checks

* Fix logic on dndGroupByControl

* Dry up savedMetrics type check

* Fixed TIME_COLUMN_OPTION

* Dried savedMetrics type check even further

* Change savedMetricsTypeCheck to defineSavedMetrics

* Change datasourceTypeCheck to isValidDatasourceType

* Fix Query path in groupByControl

* dnd_granularity_sqla now sorts Query types with is_dttm at the top

* Fixed/cleaned query sort

* Add sortedQueryColumns and proper optional chaining to granularity_sqla

* Move testQuery to core-ui, add test coverage for Queries in columnChoices

* Moved DEFAULT_METRICS to core-ui and wrote a test for defineSavedMetrics

* Add license and clean dataset test object

* Change DatasourceType.Dataset to dataset
This commit is contained in:
Lyndsi Kay Williams
2022-06-07 15:03:45 -05:00
committed by GitHub
parent d1c24f81f2
commit ba0c37d3df
40 changed files with 1125 additions and 685 deletions

View File

@@ -22,7 +22,7 @@ import { t } from '@superset-ui/core';
import { InfoTooltipWithTrigger } from '@superset-ui/chart-controls';
import Button from 'src/components/Button';
import { exploreChart } from 'src/explore/exploreUtils';
import { RootState } from 'src/SqlLab/types';
import { SqlLabRootState } from 'src/SqlLab/types';
interface ExploreCtasResultsButtonProps {
actions: {
@@ -45,7 +45,7 @@ const ExploreCtasResultsButton = ({
}: ExploreCtasResultsButtonProps) => {
const { createCtasDatasource, addInfoToast, addDangerToast } = actions;
const errorMessage = useSelector(
(state: RootState) => state.sqlLab.errorMessage,
(state: SqlLabRootState) => state.sqlLab.errorMessage,
);
const buildVizOptions = {

View File

@@ -18,12 +18,11 @@
*/
import React from 'react';
import { EmptyStateMedium } from 'src/components/EmptyState';
import { t, styled } from '@superset-ui/core';
import { Query } from 'src/SqlLab/types';
import { t, styled, QueryResponse } from '@superset-ui/core';
import QueryTable from 'src/SqlLab/components/QueryTable';
interface QueryHistoryProps {
queries: Query[];
queries: QueryResponse[];
actions: {
queryEditorSetAndSaveSql: Function;
cloneQueryToNewTab: Function;

View File

@@ -19,7 +19,7 @@
import React, { useState, useEffect } from 'react';
import Button from 'src/components/Button';
import Select from 'src/components/Select';
import { styled, t, SupersetClient } from '@superset-ui/core';
import { styled, t, SupersetClient, QueryResponse } from '@superset-ui/core';
import { debounce } from 'lodash';
import Loading from 'src/components/Loading';
import {
@@ -29,7 +29,6 @@ import {
epochTimeXYearsAgo,
} from 'src/utils/dates';
import AsyncSelect from 'src/components/AsyncSelect';
import { Query } from 'src/SqlLab/types';
import { STATUS_OPTIONS, TIME_OPTIONS } from 'src/SqlLab/constants';
import QueryTable from '../QueryTable';
@@ -85,7 +84,7 @@ function QuerySearch({ actions, displayLimit }: QuerySearchProps) {
const [from, setFrom] = useState<string>('28 days ago');
const [to, setTo] = useState<string>('now');
const [status, setStatus] = useState<string>('success');
const [queriesArray, setQueriesArray] = useState<Query[]>([]);
const [queriesArray, setQueriesArray] = useState<QueryResponse[]>([]);
const [queriesLoading, setQueriesLoading] = useState<boolean>(true);
const getTimeFromSelection = (selection: string) => {

View File

@@ -19,7 +19,7 @@
import React from 'react';
import Label from 'src/components/Label';
import { STATE_TYPE_MAP } from 'src/SqlLab/constants';
import { Query } from 'src/SqlLab/types';
import { Query } from '@superset-ui/core';
interface QueryStateLabelProps {
query: Query;

View File

@@ -21,14 +21,14 @@ import moment from 'moment';
import Card from 'src/components/Card';
import ProgressBar from 'src/components/ProgressBar';
import Label from 'src/components/Label';
import { t, useTheme } from '@superset-ui/core';
import { t, useTheme, QueryResponse } from '@superset-ui/core';
import { useSelector } from 'react-redux';
import TableView from 'src/components/TableView';
import Button from 'src/components/Button';
import { fDuration } from 'src/utils/dates';
import Icons from 'src/components/Icons';
import { Tooltip } from 'src/components/Tooltip';
import { Query, RootState } from 'src/SqlLab/types';
import { SqlLabRootState } from 'src/SqlLab/types';
import ModalTrigger from 'src/components/ModalTrigger';
import { UserWithPermissionsAndRoles as User } from 'src/types/bootstrapTypes';
import ResultSet from '../ResultSet';
@@ -36,7 +36,7 @@ import HighlightedSql from '../HighlightedSql';
import { StaticPosition, verticalAlign, StyledTooltip } from './styles';
interface QueryTableQuery
extends Omit<Query, 'state' | 'sql' | 'progress' | 'results'> {
extends Omit<QueryResponse, 'state' | 'sql' | 'progress' | 'results'> {
state?: Record<string, any>;
sql?: Record<string, any>;
progress?: Record<string, any>;
@@ -52,7 +52,7 @@ interface QueryTableProps {
clearQueryResults: Function;
removeQuery: Function;
};
queries?: Query[];
queries?: QueryResponse[];
onUserClicked?: Function;
onDbClicked?: Function;
displayLimit: number;
@@ -91,7 +91,7 @@ const QueryTable = ({
[columns],
);
const user = useSelector<RootState, User>(state => state.sqlLab.user);
const user = useSelector<SqlLabRootState, User>(state => state.sqlLab.user);
const {
queryEditorSetAndSaveSql,
@@ -102,15 +102,15 @@ const QueryTable = ({
} = actions;
const data = useMemo(() => {
const restoreSql = (query: Query) => {
const restoreSql = (query: QueryResponse) => {
queryEditorSetAndSaveSql({ id: query.sqlEditorId }, query.sql);
};
const openQueryInNewTab = (query: Query) => {
const openQueryInNewTab = (query: QueryResponse) => {
cloneQueryToNewTab(query, true);
};
const openAsyncResults = (query: Query, displayLimit: number) => {
const openAsyncResults = (query: QueryResponse, displayLimit: number) => {
fetchQueryResults(query, displayLimit);
};

View File

@@ -19,19 +19,9 @@
import React, { CSSProperties } from 'react';
import ButtonGroup from 'src/components/ButtonGroup';
import Alert from 'src/components/Alert';
import moment from 'moment';
import { RadioChangeEvent } from 'src/components';
import Button from 'src/components/Button';
import shortid from 'shortid';
import rison from 'rison';
import {
styled,
t,
makeApi,
SupersetClient,
JsonResponse,
} from '@superset-ui/core';
import { debounce } from 'lodash';
import { styled, t, QueryResponse } from '@superset-ui/core';
import ErrorMessageWithStackTrace from 'src/components/ErrorMessage/ErrorMessageWithStackTrace';
import { SaveDatasetModal } from 'src/SqlLab/components/SaveDatasetModal';
import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
@@ -42,26 +32,12 @@ import FilterableTable, {
} from 'src/components/FilterableTable';
import CopyToClipboard from 'src/components/CopyToClipboard';
import { prepareCopyToClipboardTabularData } from 'src/utils/common';
import { exploreChart } from 'src/explore/exploreUtils';
import { CtasEnum } from 'src/SqlLab/actions/sqlLab';
import { Query } from 'src/SqlLab/types';
import ExploreCtasResultsButton from '../ExploreCtasResultsButton';
import ExploreResultsButton from '../ExploreResultsButton';
import HighlightedSql from '../HighlightedSql';
import QueryStateLabel from '../QueryStateLabel';
enum DatasetRadioState {
SAVE_NEW = 1,
OVERWRITE_DATASET = 2,
}
const EXPLORE_CHART_DEFAULT = {
metrics: [],
groupby: [],
time_range: 'No filter',
viz_type: 'table',
};
enum LIMITING_FACTOR {
QUERY = 'QUERY',
QUERY_AND_DROPDOWN = 'QUERY_AND_DROPDOWN',
@@ -71,19 +47,6 @@ enum LIMITING_FACTOR {
const LOADING_STYLES: CSSProperties = { position: 'relative', minHeight: 100 };
interface DatasetOwner {
first_name: string;
id: number;
last_name: string;
username: string;
}
interface DatasetOptionAutocomplete {
value: string;
datasetId: number;
owners: [DatasetOwner];
}
interface ResultSetProps {
showControls?: boolean;
actions: Record<string, any>;
@@ -92,7 +55,7 @@ interface ResultSetProps {
database?: Record<string, any>;
displayLimit: number;
height: number;
query: Query;
query: QueryResponse;
search?: boolean;
showSql?: boolean;
visualize?: boolean;
@@ -105,12 +68,6 @@ interface ResultSetState {
showExploreResultsButton: boolean;
data: Record<string, any>[];
showSaveDatasetModal: boolean;
newSaveDatasetName: string;
saveDatasetRadioBtnState: number;
shouldOverwriteDataSet: boolean;
datasetToOverwrite: Record<string, any>;
saveModalAutocompleteValue: string;
userDatasetOptions: DatasetOptionAutocomplete[];
alertIsOpen: boolean;
}
@@ -145,44 +102,6 @@ const ResultSetErrorMessage = styled.div`
padding-top: ${({ theme }) => 4 * theme.gridUnit}px;
`;
const ResultSetRowsReturned = styled.span`
white-space: nowrap;
text-overflow: ellipsis;
width: 100%;
overflow: hidden;
display: inline-block;
`;
const LimitMessage = styled.span`
color: ${({ theme }) => theme.colors.secondary.light1};
margin-left: ${({ theme }) => theme.gridUnit * 2}px;
`;
const updateDataset = async (
dbId: number,
datasetId: number,
sql: string,
columns: Array<Record<string, any>>,
owners: [number],
overrideColumns: boolean,
) => {
const endpoint = `api/v1/dataset/${datasetId}?override_columns=${overrideColumns}`;
const headers = { 'Content-Type': 'application/json' };
const body = JSON.stringify({
sql,
columns,
owners,
database_id: dbId,
});
const data: JsonResponse = await SupersetClient.put({
endpoint,
headers,
body,
});
return data.json.result;
};
export default class ResultSet extends React.PureComponent<
ResultSetProps,
ResultSetState
@@ -203,12 +122,6 @@ export default class ResultSet extends React.PureComponent<
showExploreResultsButton: false,
data: [],
showSaveDatasetModal: false,
newSaveDatasetName: this.getDefaultDatasetName(),
saveDatasetRadioBtnState: DatasetRadioState.SAVE_NEW,
shouldOverwriteDataSet: false,
datasetToOverwrite: {},
saveModalAutocompleteValue: '',
userDatasetOptions: [],
alertIsOpen: false,
};
this.changeSearch = this.changeSearch.bind(this);
@@ -217,31 +130,11 @@ export default class ResultSet extends React.PureComponent<
this.reFetchQueryResults = this.reFetchQueryResults.bind(this);
this.toggleExploreResultsButton =
this.toggleExploreResultsButton.bind(this);
this.handleSaveInDataset = this.handleSaveInDataset.bind(this);
this.handleHideSaveModal = this.handleHideSaveModal.bind(this);
this.handleDatasetNameChange = this.handleDatasetNameChange.bind(this);
this.handleSaveDatasetRadioBtnState =
this.handleSaveDatasetRadioBtnState.bind(this);
this.handleOverwriteCancel = this.handleOverwriteCancel.bind(this);
this.handleOverwriteDataset = this.handleOverwriteDataset.bind(this);
this.handleOverwriteDatasetOption =
this.handleOverwriteDatasetOption.bind(this);
this.handleSaveDatasetModalSearch = debounce(
this.handleSaveDatasetModalSearch.bind(this),
1000,
);
this.handleFilterAutocompleteOption =
this.handleFilterAutocompleteOption.bind(this);
this.handleOnChangeAutoComplete =
this.handleOnChangeAutoComplete.bind(this);
this.handleExploreBtnClick = this.handleExploreBtnClick.bind(this);
}
async componentDidMount() {
// only do this the first time the component is rendered/mounted
this.reRunQueryIfSessionTimeoutErrorOnMount();
const userDatasetsOwned = await this.getUserDatasets();
this.setState({ userDatasetOptions: userDatasetsOwned });
}
UNSAFE_componentWillReceiveProps(nextProps: ResultSetProps) {
@@ -273,186 +166,7 @@ export default class ResultSet extends React.PureComponent<
}
};
getDefaultDatasetName = () =>
`${this.props.query.tab} ${moment().format('MM/DD/YYYY HH:mm:ss')}`;
handleOnChangeAutoComplete = () => {
this.setState({ datasetToOverwrite: {} });
};
handleOverwriteDataset = async () => {
const { sql, results, dbId } = this.props.query;
const { datasetToOverwrite } = this.state;
await updateDataset(
dbId,
datasetToOverwrite.datasetId,
sql,
results.selected_columns.map(d => ({
column_name: d.name,
type: d.type,
is_dttm: d.is_dttm,
})),
datasetToOverwrite.owners.map((o: DatasetOwner) => o.id),
true,
);
this.setState({
showSaveDatasetModal: false,
shouldOverwriteDataSet: false,
datasetToOverwrite: {},
newSaveDatasetName: this.getDefaultDatasetName(),
});
exploreChart({
...EXPLORE_CHART_DEFAULT,
datasource: `${datasetToOverwrite.datasetId}__table`,
all_columns: results.selected_columns.map(d => d.name),
});
};
handleSaveInDataset = () => {
// if user wants to overwrite a dataset we need to prompt them
if (
this.state.saveDatasetRadioBtnState ===
DatasetRadioState.OVERWRITE_DATASET
) {
this.setState({ shouldOverwriteDataSet: true });
return;
}
const { schema, sql, dbId } = this.props.query;
let { templateParams } = this.props.query;
const selectedColumns = this.props.query?.results?.selected_columns || [];
// The filters param is only used to test jinja templates.
// Remove the special filters entry from the templateParams
// before saving the dataset.
if (templateParams) {
const p = JSON.parse(templateParams);
/* eslint-disable-next-line no-underscore-dangle */
if (p._filters) {
/* eslint-disable-next-line no-underscore-dangle */
delete p._filters;
templateParams = JSON.stringify(p);
}
}
this.props.actions
.createDatasource({
schema,
sql,
dbId,
templateParams,
datasourceName: this.state.newSaveDatasetName,
columns: selectedColumns,
})
.then((data: { table_id: number }) => {
exploreChart({
datasource: `${data.table_id}__table`,
metrics: [],
groupby: [],
time_range: 'No filter',
viz_type: 'table',
all_columns: selectedColumns.map(c => c.name),
row_limit: 1000,
});
})
.catch(() => {
this.props.actions.addDangerToast(
t('An error occurred saving dataset'),
);
});
this.setState({
showSaveDatasetModal: false,
newSaveDatasetName: this.getDefaultDatasetName(),
});
};
handleOverwriteDatasetOption = (
_data: string,
option: Record<string, any>,
) => {
this.setState({ datasetToOverwrite: option });
};
handleDatasetNameChange = (e: React.FormEvent<HTMLInputElement>) => {
// @ts-expect-error
this.setState({ newSaveDatasetName: e.target.value });
};
handleHideSaveModal = () => {
this.setState({
showSaveDatasetModal: false,
shouldOverwriteDataSet: false,
});
};
handleSaveDatasetRadioBtnState = (e: RadioChangeEvent) => {
this.setState({ saveDatasetRadioBtnState: Number(e.target.value) });
};
handleOverwriteCancel = () => {
this.setState({ shouldOverwriteDataSet: false, datasetToOverwrite: {} });
};
handleExploreBtnClick = () => {
this.setState({
showSaveDatasetModal: true,
});
};
getUserDatasets = async (searchText = '') => {
// Making sure that autocomplete input has a value before rendering the dropdown
// Transforming the userDatasetsOwned data for SaveModalComponent)
const { userId } = this.props.user;
if (userId) {
const queryParams = rison.encode({
filters: [
{
col: 'table_name',
opr: 'ct',
value: searchText,
},
{
col: 'owners',
opr: 'rel_m_m',
value: userId,
},
],
order_column: 'changed_on_delta_humanized',
order_direction: 'desc',
});
const response = await makeApi({
method: 'GET',
endpoint: '/api/v1/dataset',
})(`q=${queryParams}`);
return response.result.map(
(r: { table_name: string; id: number; owners: [DatasetOwner] }) => ({
value: r.table_name,
datasetId: r.id,
owners: r.owners,
}),
);
}
return null;
};
handleSaveDatasetModalSearch = async (searchText: string) => {
const userDatasetsOwned = await this.getUserDatasets(searchText);
this.setState({ userDatasetOptions: userDatasetsOwned });
};
handleFilterAutocompleteOption = (
inputValue: string,
option: { value: string; datasetId: number },
) => option.value.toLowerCase().includes(inputValue.toLowerCase());
clearQueryResults(query: Query) {
clearQueryResults(query: QueryResponse) {
this.props.actions.clearQueryResults(query);
}
@@ -477,11 +191,11 @@ export default class ResultSet extends React.PureComponent<
this.setState({ searchText: event.target.value });
}
fetchResults(query: Query) {
fetchResults(query: QueryResponse) {
this.props.actions.fetchQueryResults(query, this.props.displayLimit);
}
reFetchQueryResults(query: Query) {
reFetchQueryResults(query: QueryResponse) {
this.props.actions.reFetchQueryResults(query);
}
@@ -503,55 +217,31 @@ export default class ResultSet extends React.PureComponent<
}
const { columns } = this.props.query.results;
// Added compute logic to stop user from being able to Save & Explore
const {
saveDatasetRadioBtnState,
newSaveDatasetName,
datasetToOverwrite,
saveModalAutocompleteValue,
shouldOverwriteDataSet,
userDatasetOptions,
showSaveDatasetModal,
} = this.state;
const disableSaveAndExploreBtn =
(saveDatasetRadioBtnState === DatasetRadioState.SAVE_NEW &&
newSaveDatasetName.length === 0) ||
(saveDatasetRadioBtnState === DatasetRadioState.OVERWRITE_DATASET &&
Object.keys(datasetToOverwrite).length === 0 &&
saveModalAutocompleteValue.length === 0);
const { showSaveDatasetModal } = this.state;
const { query } = this.props;
return (
<ResultSetControls>
<SaveDatasetModal
visible={showSaveDatasetModal}
onOk={this.handleSaveInDataset}
saveDatasetRadioBtnState={saveDatasetRadioBtnState}
shouldOverwriteDataset={shouldOverwriteDataSet}
defaultCreateDatasetValue={newSaveDatasetName}
userDatasetOptions={userDatasetOptions}
disableSaveAndExploreBtn={disableSaveAndExploreBtn}
onHide={this.handleHideSaveModal}
handleDatasetNameChange={this.handleDatasetNameChange}
handleSaveDatasetRadioBtnState={this.handleSaveDatasetRadioBtnState}
handleOverwriteCancel={this.handleOverwriteCancel}
handleOverwriteDataset={this.handleOverwriteDataset}
handleOverwriteDatasetOption={this.handleOverwriteDatasetOption}
handleSaveDatasetModalSearch={this.handleSaveDatasetModalSearch}
filterAutocompleteOption={this.handleFilterAutocompleteOption}
onChangeAutoComplete={this.handleOnChangeAutoComplete}
onHide={() => this.setState({ showSaveDatasetModal: false })}
buttonTextOnSave={t('Save & Explore')}
buttonTextOnOverwrite={t('Overwrite & Explore')}
modalDescription={t(
'Save this query as a virtual dataset to continue exploring',
)}
datasource={query}
/>
<ResultSetButtons>
{this.props.visualize &&
this.props.database?.allows_virtual_table_explore && (
<ExploreResultsButton
database={this.props.database}
onClick={this.handleExploreBtnClick}
onClick={() => this.setState({ showSaveDatasetModal: true })}
/>
)}
{this.props.csv && (
<Button
buttonSize="small"
href={`/superset/csv/${this.props.query.id}`}
>
<Button buttonSize="small" href={`/superset/csv/${query.id}`}>
<i className="fa fa-file-text-o" /> {t('Download to CSV')}
</Button>
)}
@@ -587,10 +277,6 @@ export default class ResultSet extends React.PureComponent<
return <div />;
}
onAlertClose = () => {
this.setState({ alertIsOpen: false });
};
renderRowsReturned() {
const { results, rows, queryLimit, limitingFactor } = this.props.query;
let limitMessage;
@@ -646,17 +332,17 @@ export default class ResultSet extends React.PureComponent<
return (
<ReturnedRows>
{!limitReached && !shouldUseDefaultDropdownAlert && (
<ResultSetRowsReturned title={tooltipText}>
<span title={tooltipText}>
{rowsReturnedMessage}
<LimitMessage>{limitMessage}</LimitMessage>
</ResultSetRowsReturned>
<span>{limitMessage}</span>
</span>
)}
{!limitReached && shouldUseDefaultDropdownAlert && (
<div ref={this.calculateAlertRefHeight}>
<Alert
type="warning"
message={t('%(rows)d rows returned', { rows })}
onClose={this.onAlertClose}
onClose={() => this.setState({ alertIsOpen: false })}
description={t(
'The number of rows displayed is limited to %s by the dropdown.',
rows,
@@ -668,7 +354,7 @@ export default class ResultSet extends React.PureComponent<
<div ref={this.calculateAlertRefHeight}>
<Alert
type="warning"
onClose={this.onAlertClose}
onClose={() => this.setState({ alertIsOpen: false })}
message={t('%(rows)d rows returned', { rows: rowsCount })}
description={
isAdmin
@@ -691,9 +377,7 @@ export default class ResultSet extends React.PureComponent<
exploreDBId = this.props.database.explore_database_id;
}
if (this.props.showSql) {
sql = <HighlightedSql sql={query.sql} />;
}
if (this.props.showSql) sql = <HighlightedSql sql={query.sql} />;
if (query.state === 'stopped') {
return <Alert type="warning" message={t('Query was stopped')} />;

View File

@@ -17,44 +17,60 @@
* under the License.
*/
import React from 'react';
import { shallow } from 'enzyme';
import { Radio } from 'src/components/Radio';
import { AutoComplete } from 'src/components';
import { Input } from 'src/components/Input';
import { QueryResponse, testQuery } from '@superset-ui/core';
import { SaveDatasetModal } from 'src/SqlLab/components/SaveDatasetModal';
import { render, screen } from 'spec/helpers/testing-library';
describe('SaveDatasetModal', () => {
const mockedProps = {
visible: false,
onOk: () => {},
onHide: () => {},
handleDatasetNameChange: () => {},
handleSaveDatasetRadioBtnState: () => {},
saveDatasetRadioBtnState: 1,
handleOverwriteCancel: () => {},
handleOverwriteDataset: () => {},
handleOverwriteDatasetOption: () => {},
defaultCreateDatasetValue: 'someDatasets',
shouldOverwriteDataset: false,
userDatasetOptions: [],
disableSaveAndExploreBtn: false,
handleSaveDatasetModalSearch: () => Promise,
filterAutocompleteOption: () => false,
onChangeAutoComplete: () => {},
};
it('renders a radio group btn', () => {
// @ts-ignore
const wrapper = shallow(<SaveDatasetModal {...mockedProps} />);
expect(wrapper.find(Radio.Group)).toExist();
const mockedProps = {
visible: true,
onHide: () => {},
buttonTextOnSave: 'Save',
buttonTextOnOverwrite: 'Overwrite',
datasource: testQuery as QueryResponse,
};
describe('SaveDatasetModal RTL', () => {
it('renders a "Save as new" field', () => {
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
const saveRadioBtn = screen.getByRole('radio', {
name: /save as new unimportant/i,
});
const fieldLabel = screen.getByText(/save as new/i);
const inputField = screen.getByRole('textbox');
const inputFieldText = screen.getByDisplayValue(/unimportant/i);
expect(saveRadioBtn).toBeVisible();
expect(fieldLabel).toBeVisible();
expect(inputField).toBeVisible();
expect(inputFieldText).toBeVisible();
});
it('renders a autocomplete', () => {
// @ts-ignore
const wrapper = shallow(<SaveDatasetModal {...mockedProps} />);
expect(wrapper.find(AutoComplete)).toExist();
it('renders an "Overwrite existing" field', () => {
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
const overwriteRadioBtn = screen.getByRole('radio', {
name: /overwrite existing select or type dataset name/i,
});
const fieldLabel = screen.getByText(/overwrite existing/i);
const inputField = screen.getByRole('combobox');
const placeholderText = screen.getByText(/select or type dataset name/i);
expect(overwriteRadioBtn).toBeVisible();
expect(fieldLabel).toBeVisible();
expect(inputField).toBeVisible();
expect(placeholderText).toBeVisible();
});
it('renders an input form', () => {
// @ts-ignore
const wrapper = shallow(<SaveDatasetModal {...mockedProps} />);
expect(wrapper.find(Input)).toExist();
it('renders a save button', () => {
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
expect(screen.getByRole('button', { name: /save/i })).toBeVisible();
});
it('renders a close button', () => {
render(<SaveDatasetModal {...mockedProps} />, { useRedux: true });
expect(screen.getByRole('button', { name: /close/i })).toBeVisible();
});
});

View File

@@ -17,153 +17,356 @@
* under the License.
*/
import React, { FunctionComponent } from 'react';
import { AutoCompleteProps } from 'antd/lib/auto-complete';
import React, { FunctionComponent, useState } from 'react';
import { Radio } from 'src/components/Radio';
import { AutoComplete, RadioChangeEvent } from 'src/components';
import { Input } from 'src/components/Input';
import StyledModal from 'src/components/Modal';
import Button from 'src/components/Button';
import { styled, t } from '@superset-ui/core';
import {
styled,
t,
SupersetClient,
makeApi,
JsonResponse,
JsonObject,
QueryResponse,
} from '@superset-ui/core';
import { useSelector, useDispatch } from 'react-redux';
import moment from 'moment';
import rison from 'rison';
import { createDatasource } from 'src/SqlLab/actions/sqlLab';
import { addDangerToast } from 'src/components/MessageToasts/actions';
import { UserWithPermissionsAndRoles as User } from 'src/types/bootstrapTypes';
import {
DatasetRadioState,
EXPLORE_CHART_DEFAULT,
DatasetOwner,
DatasetOptionAutocomplete,
SqlLabExploreRootState,
getInitialState,
ExploreDatasource,
} from 'src/SqlLab/types';
import { exploreChart } from 'src/explore/exploreUtils';
interface SaveDatasetModalProps {
visible: boolean;
onOk: () => void;
onHide: () => void;
handleDatasetNameChange: (e: React.FormEvent<HTMLInputElement>) => void;
handleSaveDatasetModalSearch: (searchText: string) => Promise<void>;
filterAutocompleteOption: (
inputValue: string,
option: { value: string; datasetId: number },
) => boolean;
handleSaveDatasetRadioBtnState: (e: RadioChangeEvent) => void;
handleOverwriteCancel: () => void;
handleOverwriteDataset: () => void;
handleOverwriteDatasetOption: (
data: string,
option: Record<string, any>,
) => void;
onChangeAutoComplete: () => void;
defaultCreateDatasetValue: string;
disableSaveAndExploreBtn: boolean;
saveDatasetRadioBtnState: number;
shouldOverwriteDataset: boolean;
userDatasetOptions: AutoCompleteProps['options'];
buttonTextOnSave: string;
buttonTextOnOverwrite: string;
modalDescription?: string;
datasource: ExploreDatasource;
}
const Styles = styled.div`
.smd-body {
.sdm-body {
margin: 0 8px;
}
.smd-input {
.sdm-input {
margin-left: 45px;
width: 401px;
}
.smd-autocomplete {
.sdm-autocomplete {
margin-left: 8px;
width: 401px;
}
.smd-radio {
.sdm-radio {
display: block;
height: 30px;
margin: 10px 0px;
line-height: 30px;
}
.smd-overwrite-msg {
.sdm-overwrite-msg {
margin: 7px;
}
`;
const updateDataset = async (
dbId: number,
datasetId: number,
sql: string,
columns: Array<Record<string, any>>,
owners: [number],
overrideColumns: boolean,
) => {
const endpoint = `api/v1/dataset/${datasetId}?override_columns=${overrideColumns}`;
const headers = { 'Content-Type': 'application/json' };
const body = JSON.stringify({
sql,
columns,
owners,
database_id: dbId,
});
const data: JsonResponse = await SupersetClient.put({
endpoint,
headers,
body,
});
return data.json.result;
};
// eslint-disable-next-line no-empty-pattern
export const SaveDatasetModal: FunctionComponent<SaveDatasetModalProps> = ({
visible,
onOk,
onHide,
handleDatasetNameChange,
handleSaveDatasetRadioBtnState,
saveDatasetRadioBtnState,
shouldOverwriteDataset,
handleOverwriteCancel,
handleOverwriteDataset,
handleOverwriteDatasetOption,
defaultCreateDatasetValue,
disableSaveAndExploreBtn,
handleSaveDatasetModalSearch,
filterAutocompleteOption,
userDatasetOptions,
onChangeAutoComplete,
}) => (
<StyledModal
show={visible}
title="Save or Overwrite Dataset"
onHide={onHide}
footer={
<>
buttonTextOnSave,
buttonTextOnOverwrite,
modalDescription,
datasource,
}) => {
const query = datasource as QueryResponse;
const getDefaultDatasetName = () =>
`${query.tab} ${moment().format('MM/DD/YYYY HH:mm:ss')}`;
const [datasetName, setDatasetName] = useState(getDefaultDatasetName());
const [newOrOverwrite, setNewOrOverwrite] = useState(
DatasetRadioState.SAVE_NEW,
);
const [shouldOverwriteDataset, setShouldOverwriteDataset] = useState(false);
const [userDatasetOptions, setUserDatasetOptions] = useState<
DatasetOptionAutocomplete[]
>([]);
const [datasetToOverwrite, setDatasetToOverwrite] = useState<
Record<string, any>
>({});
const [autocompleteValue, setAutocompleteValue] = useState('');
const user = useSelector<SqlLabExploreRootState, User>(user =>
getInitialState(user),
);
const dispatch = useDispatch<(dispatch: any) => Promise<JsonObject>>();
const handleOverwriteDataset = async () => {
await updateDataset(
query.dbId,
datasetToOverwrite.datasetId,
query.sql,
query.results.selected_columns.map(
(d: { name: string; type: string; is_dttm: boolean }) => ({
column_name: d.name,
type: d.type,
is_dttm: d.is_dttm,
}),
),
datasetToOverwrite.owners.map((o: DatasetOwner) => o.id),
true,
);
setShouldOverwriteDataset(false);
setDatasetToOverwrite({});
setDatasetName(getDefaultDatasetName());
exploreChart({
...EXPLORE_CHART_DEFAULT,
datasource: `${datasetToOverwrite.datasetId}__table`,
all_columns: query.results.selected_columns.map(
(d: { name: string; type: string; is_dttm: boolean }) => d.name,
),
});
};
const getUserDatasets = async (searchText = '') => {
// Making sure that autocomplete input has a value before rendering the dropdown
// Transforming the userDatasetsOwned data for SaveModalComponent)
const { userId } = user;
if (userId) {
const queryParams = rison.encode({
filters: [
{
col: 'table_name',
opr: 'ct',
value: searchText,
},
{
col: 'owners',
opr: 'rel_m_m',
value: userId,
},
],
order_column: 'changed_on_delta_humanized',
order_direction: 'desc',
});
const response = await makeApi({
method: 'GET',
endpoint: '/api/v1/dataset',
})(`q=${queryParams}`);
return response.result.map(
(r: { table_name: string; id: number; owners: [DatasetOwner] }) => ({
value: r.table_name,
datasetId: r.id,
owners: r.owners,
}),
);
}
return null;
};
const handleSaveInDataset = () => {
// if user wants to overwrite a dataset we need to prompt them
if (newOrOverwrite === DatasetRadioState.OVERWRITE_DATASET) {
setShouldOverwriteDataset(true);
return;
}
const selectedColumns = query.results.selected_columns || [];
// The filters param is only used to test jinja templates.
// Remove the special filters entry from the templateParams
// before saving the dataset.
if (query.templateParams) {
const p = JSON.parse(query.templateParams);
/* eslint-disable-next-line no-underscore-dangle */
if (p._filters) {
/* eslint-disable-next-line no-underscore-dangle */
delete p._filters;
// eslint-disable-next-line no-param-reassign
query.templateParams = JSON.stringify(p);
}
}
dispatch(
createDatasource({
schema: query.schema,
sql: query.sql,
dbId: query.dbId,
templateParams: query.templateParams,
datasourceName: datasetName,
columns: selectedColumns,
}),
)
.then((data: { table_id: number }) => {
exploreChart({
datasource: `${data.table_id}__table`,
metrics: [],
groupby: [],
time_range: 'No filter',
viz_type: 'table',
all_columns: selectedColumns.map(c => c.name),
row_limit: 1000,
});
})
.catch(() => {
addDangerToast(t('An error occurred saving dataset'));
});
setDatasetName(getDefaultDatasetName());
onHide();
};
const handleSaveDatasetModalSearch = async (searchText: string) => {
const userDatasetsOwned = await getUserDatasets(searchText);
setUserDatasetOptions(userDatasetsOwned);
};
const handleOverwriteDatasetOption = (
_data: string,
option: Record<string, any>,
) => setDatasetToOverwrite(option);
const handleDatasetNameChange = (e: React.FormEvent<HTMLInputElement>) => {
// @ts-expect-error
setDatasetName(e.target.value);
};
const handleOverwriteCancel = () => {
setShouldOverwriteDataset(false);
setDatasetToOverwrite({});
};
const disableSaveAndExploreBtn =
(newOrOverwrite === DatasetRadioState.SAVE_NEW &&
datasetName.length === 0) ||
(newOrOverwrite === DatasetRadioState.OVERWRITE_DATASET &&
Object.keys(datasetToOverwrite).length === 0 &&
autocompleteValue.length === 0);
const filterAutocompleteOption = (
inputValue: string,
option: { value: string; datasetId: number },
) => option.value.toLowerCase().includes(inputValue.toLowerCase());
return (
<StyledModal
show={visible}
title={t('Save or Overwrite Dataset')}
onHide={onHide}
footer={
<>
{!shouldOverwriteDataset && (
<Button
disabled={disableSaveAndExploreBtn}
buttonStyle="primary"
onClick={handleSaveInDataset}
>
{buttonTextOnSave}
</Button>
)}
{shouldOverwriteDataset && (
<>
<Button onClick={handleOverwriteCancel}>Back</Button>
<Button
className="md"
buttonStyle="primary"
onClick={handleOverwriteDataset}
disabled={disableSaveAndExploreBtn}
>
{buttonTextOnOverwrite}
</Button>
</>
)}
</>
}
>
<Styles>
{!shouldOverwriteDataset && (
<Button
disabled={disableSaveAndExploreBtn}
buttonStyle="primary"
onClick={onOk}
>
{t('Save & Explore')}
</Button>
<div className="sdm-body">
{modalDescription && (
<div className="sdm-prompt">{modalDescription}</div>
)}
<Radio.Group
onChange={(e: RadioChangeEvent) => {
setNewOrOverwrite(Number(e.target.value));
}}
value={newOrOverwrite}
>
<Radio className="sdm-radio" value={1}>
{t('Save as new')}
<Input
className="sdm-input"
defaultValue={datasetName}
onChange={handleDatasetNameChange}
disabled={newOrOverwrite !== 1}
/>
</Radio>
<Radio className="sdm-radio" value={2}>
{t('Overwrite existing')}
<AutoComplete
className="sdm-autocomplete"
options={userDatasetOptions}
onSelect={handleOverwriteDatasetOption}
onSearch={handleSaveDatasetModalSearch}
onChange={value => {
setDatasetToOverwrite({});
setAutocompleteValue(value);
}}
placeholder={t('Select or type dataset name')}
filterOption={filterAutocompleteOption}
disabled={newOrOverwrite !== 2}
value={autocompleteValue}
/>
</Radio>
</Radio.Group>
</div>
)}
{shouldOverwriteDataset && (
<>
<Button onClick={handleOverwriteCancel}>Back</Button>
<Button
className="md"
buttonStyle="primary"
onClick={handleOverwriteDataset}
disabled={disableSaveAndExploreBtn}
>
{t('Overwrite & Explore')}
</Button>
</>
)}
</>
}
>
<Styles>
{!shouldOverwriteDataset && (
<div className="smd-body">
<div className="smd-prompt">
Save this query as a virtual dataset to continue exploring
<div className="sdm-overwrite-msg">
{t('Are you sure you want to overwrite this dataset?')}
</div>
<Radio.Group
onChange={handleSaveDatasetRadioBtnState}
value={saveDatasetRadioBtnState}
>
<Radio className="smd-radio" value={1}>
Save as new
<Input
className="smd-input"
defaultValue={defaultCreateDatasetValue}
onChange={handleDatasetNameChange}
disabled={saveDatasetRadioBtnState !== 1}
/>
</Radio>
<Radio className="smd-radio" value={2}>
Overwrite existing
<AutoComplete
className="smd-autocomplete"
options={userDatasetOptions}
onSelect={handleOverwriteDatasetOption}
onSearch={handleSaveDatasetModalSearch}
onChange={onChangeAutoComplete}
placeholder="Select or type dataset name"
filterOption={filterAutocompleteOption}
disabled={saveDatasetRadioBtnState !== 2}
/>
</Radio>
</Radio.Group>
</div>
)}
{shouldOverwriteDataset && (
<div className="smd-overwrite-msg">
Are you sure you want to overwrite this dataset?
</div>
)}
</Styles>
</StyledModal>
);
)}
</Styles>
</StyledModal>
);
};

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import React from 'react';
import { QueryState } from 'src/SqlLab/types';
import { QueryState } from '@superset-ui/core';
interface TabStatusIconProps {
tabState: QueryState;