fix(virtual dataset sync): Sync virtual dataset columns when changing the SQL query (#30903)

Co-authored-by: Kamil Gabryjelski <kamil.gabryjelski@gmail.com>
This commit is contained in:
Jack 2025-02-07 11:16:44 -06:00 committed by GitHub
parent f9f8c5d07a
commit f3e7c64de6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 743 additions and 659 deletions

View File

@ -51,8 +51,8 @@ describe('Datasource control', () => {
)
.first()
.focus();
cy.focused().clear();
cy.focused().type(`${newMetricName}{enter}`);
cy.focused().clear({ force: true });
cy.focused().type(`${newMetricName}{enter}`, { force: true });
cy.get('[data-test="datasource-modal-save"]').click();
cy.get('.antd5-modal-confirm-btns button').contains('OK').click();

View File

@ -75,4 +75,5 @@ module.exports = {
},
],
],
testTimeout: 10000,
};

View File

@ -84,6 +84,12 @@ export interface Dataset {
filter_select?: boolean;
filter_select_enabled?: boolean;
column_names?: string[];
catalog?: string;
schema?: string;
table_name?: string;
database?: Record<string, unknown>;
normalize_columns?: boolean;
always_filter_main_dttm?: boolean;
}
export interface ControlPanelState {

View File

@ -17,7 +17,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Maybe, QueryFormMetric } from '../../types';
import { Currency, Maybe, QueryFormMetric } from '../../types';
import { Column } from './Column';
export type Aggregate =
@ -65,7 +65,7 @@ export interface Metric {
certification_details?: Maybe<string>;
certified_by?: Maybe<string>;
d3format?: Maybe<string>;
currency?: Maybe<string>;
currency?: Maybe<Currency>;
description?: Maybe<string>;
is_certified?: boolean;
verbose_name?: string;

View File

@ -173,7 +173,7 @@ describe('BigNumberWithTrendline', () => {
label: 'value',
metric_name: 'value',
d3format: '.2f',
currency: `{symbol: 'USD', symbolPosition: 'prefix' }`,
currency: { symbol: 'USD', symbolPosition: 'prefix' },
},
],
},

View File

@ -23,7 +23,6 @@ import { Radio } from 'src/components/Radio';
import Card from 'src/components/Card';
import Alert from 'src/components/Alert';
import Badge from 'src/components/Badge';
import { nanoid } from 'nanoid';
import {
css,
isFeatureEnabled,
@ -57,6 +56,7 @@ import CurrencyControl from 'src/explore/components/controls/CurrencyControl';
import CollectionTable from './CollectionTable';
import Fieldset from './Fieldset';
import Field from './Field';
import { fetchSyncedColumns, updateColumns } from './utils';
const DatasourceContainer = styled.div`
.change-warning {
@ -140,6 +140,14 @@ const StyledButtonWrapper = styled.span`
`}
`;
const sqlTooltipOptions = {
placement: 'topRight',
title: t(
'If changes are made to your SQL query, ' +
'columns in your dataset will be synced when saving the dataset.',
),
};
const checkboxGenerator = (d, onChange) => (
<CheckboxControl value={d} onChange={onChange} />
);
@ -694,116 +702,27 @@ class DatasourceEditor extends PureComponent {
});
}
updateColumns(cols) {
// cols: Array<{column_name: string; is_dttm: boolean; type: string;}>
const { databaseColumns } = this.state;
const databaseColumnNames = cols.map(col => col.column_name);
const currentCols = databaseColumns.reduce(
(agg, col) => ({
...agg,
[col.column_name]: col,
}),
{},
);
const finalColumns = [];
const results = {
added: [],
modified: [],
removed: databaseColumns
.map(col => col.column_name)
.filter(col => !databaseColumnNames.includes(col)),
};
cols.forEach(col => {
const currentCol = currentCols[col.column_name];
if (!currentCol) {
// new column
finalColumns.push({
id: nanoid(),
column_name: col.column_name,
type: col.type,
groupby: true,
filterable: true,
is_dttm: col.is_dttm,
});
results.added.push(col.column_name);
} else if (
currentCol.type !== col.type ||
(!currentCol.is_dttm && col.is_dttm)
) {
// modified column
finalColumns.push({
...currentCol,
type: col.type,
is_dttm: currentCol.is_dttm || col.is_dttm,
});
results.modified.push(col.column_name);
} else {
// unchanged
finalColumns.push(currentCol);
}
});
if (
results.added.length ||
results.modified.length ||
results.removed.length
) {
this.setColumns({ databaseColumns: finalColumns });
}
return results;
}
syncMetadata() {
async syncMetadata() {
const { datasource } = this.state;
const params = {
datasource_type: datasource.type || datasource.datasource_type,
database_name:
datasource.database.database_name || datasource.database.name,
catalog_name: datasource.catalog,
schema_name: datasource.schema,
table_name: datasource.table_name,
normalize_columns: datasource.normalize_columns,
always_filter_main_dttm: datasource.always_filter_main_dttm,
};
Object.entries(params).forEach(([key, value]) => {
// rison can't encode the undefined value
if (value === undefined) {
params[key] = null;
}
});
const endpoint = `/datasource/external_metadata_by_name/?q=${rison.encode_uri(
params,
)}`;
this.setState({ metadataLoading: true });
SupersetClient.get({ endpoint })
.then(({ json }) => {
const results = this.updateColumns(json);
if (results.modified.length) {
this.props.addSuccessToast(
t('Modified columns: %s', results.modified.join(', ')),
try {
const newCols = await fetchSyncedColumns(datasource);
const columnChanges = updateColumns(
datasource.columns,
newCols,
this.props.addSuccessToast,
);
}
if (results.removed.length) {
this.props.addSuccessToast(
t('Removed columns: %s', results.removed.join(', ')),
);
}
if (results.added.length) {
this.props.addSuccessToast(
t('New columns added: %s', results.added.join(', ')),
);
}
this.setColumns({ databaseColumns: columnChanges.finalColumns });
this.props.addSuccessToast(t('Metadata has been synced'));
this.setState({ metadataLoading: false });
})
.catch(response =>
getClientErrorObject(response).then(({ error, statusText }) => {
} catch (error) {
const { error: clientError, statusText } =
await getClientErrorObject(error);
this.props.addDangerToast(
error || statusText || t('An error has occurred'),
clientError || statusText || t('An error has occurred'),
);
this.setState({ metadataLoading: false });
}),
);
}
}
findDuplicates(arr, accessor) {
@ -1146,6 +1065,7 @@ class DatasourceEditor extends PureComponent {
maxLines={Infinity}
readOnly={!this.state.isEditMode}
resize="both"
tooltipOptions={sqlTooltipOptions}
/>
}
/>

View File

@ -17,11 +17,11 @@
* under the License.
*/
import { FunctionComponent, useState, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import Alert from 'src/components/Alert';
import Button from 'src/components/Button';
import {
isDefined,
Metric,
styled,
SupersetClient,
getClientErrorObject,
@ -33,7 +33,16 @@ import Modal from 'src/components/Modal';
import AsyncEsmComponent from 'src/components/AsyncEsmComponent';
import ErrorMessageWithStackTrace from 'src/components/ErrorMessage/ErrorMessageWithStackTrace';
import withToasts from 'src/components/MessageToasts/withToasts';
import { useSelector } from 'react-redux';
import {
startMetaDataLoading,
stopMetaDataLoading,
syncDatasourceMetadata,
} from 'src/explore/actions/exploreActions';
import {
fetchSyncedColumns,
updateColumns,
} from 'src/components/Datasource/utils';
import { DatasetObject } from '../../features/datasets/types';
const DatasourceEditor = AsyncEsmComponent(() => import('./DatasourceEditor'));
@ -60,14 +69,17 @@ const StyledDatasourceModal = styled(Modal)`
interface DatasourceModalProps {
addSuccessToast: (msg: string) => void;
datasource: any;
addDangerToast: (msg: string) => void;
datasource: DatasetObject;
onChange: () => {};
onDatasourceSave: (datasource: object, errors?: Array<any>) => {};
onHide: () => {};
show: boolean;
}
function buildExtraJsonObject(item: Record<string, unknown>) {
function buildExtraJsonObject(
item: DatasetObject['metrics'][0] | DatasetObject['columns'][0],
) {
const certification =
item?.certified_by || item?.certification_details
? {
@ -83,18 +95,14 @@ function buildExtraJsonObject(item: Record<string, unknown>) {
const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
addSuccessToast,
addDangerToast,
datasource,
onDatasourceSave,
onHide,
show,
}) => {
const [currentDatasource, setCurrentDatasource] = useState({
...datasource,
metrics: datasource?.metrics?.map((metric: Metric) => ({
...metric,
currency: JSON.parse(metric.currency || 'null'),
})),
});
const dispatch = useDispatch();
const [currentDatasource, setCurrentDatasource] = useState(datasource);
const currencies = useSelector<
{
common: {
@ -108,41 +116,30 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
const [isEditing, setIsEditing] = useState<boolean>(false);
const dialog = useRef<any>(null);
const [modal, contextHolder] = Modal.useModal();
const onConfirmSave = () => {
// Pull out extra fields into the extra object
const schema =
currentDatasource.tableSelector?.schema ||
currentDatasource.databaseSelector?.schema ||
currentDatasource.schema;
setIsSaving(true);
SupersetClient.put({
endpoint: `/api/v1/dataset/${currentDatasource.id}`,
jsonPayload: {
table_name: currentDatasource.table_name,
database_id: currentDatasource.database?.id,
sql: currentDatasource.sql,
filter_select_enabled: currentDatasource.filter_select_enabled,
fetch_values_predicate: currentDatasource.fetch_values_predicate,
schema,
description: currentDatasource.description,
main_dttm_col: currentDatasource.main_dttm_col,
normalize_columns: currentDatasource.normalize_columns,
always_filter_main_dttm: currentDatasource.always_filter_main_dttm,
offset: currentDatasource.offset,
default_endpoint: currentDatasource.default_endpoint,
const buildPayload = (datasource: Record<string, any>) => ({
table_name: datasource.table_name,
database_id: datasource.database?.id,
sql: datasource.sql,
filter_select_enabled: datasource.filter_select_enabled,
fetch_values_predicate: datasource.fetch_values_predicate,
schema:
datasource.tableSelector?.schema ||
datasource.databaseSelector?.schema ||
datasource.schema,
description: datasource.description,
main_dttm_col: datasource.main_dttm_col,
normalize_columns: datasource.normalize_columns,
always_filter_main_dttm: datasource.always_filter_main_dttm,
offset: datasource.offset,
default_endpoint: datasource.default_endpoint,
cache_timeout:
currentDatasource.cache_timeout === ''
? null
: currentDatasource.cache_timeout,
is_sqllab_view: currentDatasource.is_sqllab_view,
template_params: currentDatasource.template_params,
extra: currentDatasource.extra,
is_managed_externally: currentDatasource.is_managed_externally,
external_url: currentDatasource.external_url,
metrics: currentDatasource?.metrics?.map(
(metric: Record<string, unknown>) => {
datasource.cache_timeout === '' ? null : datasource.cache_timeout,
is_sqllab_view: datasource.is_sqllab_view,
template_params: datasource.template_params,
extra: datasource.extra,
is_managed_externally: datasource.is_managed_externally,
external_url: datasource.external_url,
metrics: datasource?.metrics?.map((metric: DatasetObject['metrics'][0]) => {
const metricBody: any = {
expression: metric.expression,
description: metric.description,
@ -161,10 +158,9 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
metricBody.id = metric.id;
}
return metricBody;
},
),
columns: currentDatasource?.columns?.map(
(column: Record<string, unknown>) => ({
}),
columns: datasource?.columns?.map(
(column: DatasetObject['columns'][0]) => ({
id: typeof column.id === 'number' ? column.id : undefined,
column_name: column.column_name,
type: column.type,
@ -181,18 +177,47 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
extra: buildExtraJsonObject(column),
}),
),
owners: currentDatasource.owners.map(
owners: datasource.owners.map(
(o: Record<string, number>) => o.value || o.id,
),
},
})
.then(() => {
addSuccessToast(t('The dataset has been saved'));
return SupersetClient.get({
});
const onConfirmSave = async () => {
// Pull out extra fields into the extra object
setIsSaving(true);
try {
await SupersetClient.put({
endpoint: `/api/v1/dataset/${currentDatasource.id}`,
jsonPayload: buildPayload(currentDatasource),
});
if (datasource.sql !== currentDatasource.sql) {
// if sql has changed, save a second time with synced columns
dispatch(startMetaDataLoading());
try {
const columnJson = await fetchSyncedColumns(currentDatasource);
const columnChanges = updateColumns(
currentDatasource.columns,
columnJson,
addSuccessToast,
);
currentDatasource.columns = columnChanges.finalColumns;
dispatch(syncDatasourceMetadata(currentDatasource));
dispatch(stopMetaDataLoading());
addSuccessToast(t('Metadata has been synced'));
} catch (error) {
dispatch(stopMetaDataLoading());
addDangerToast(
t('An error has occurred while syncing virtual dataset columns'),
);
}
await SupersetClient.put({
endpoint: `/api/v1/dataset/${currentDatasource.id}`,
jsonPayload: buildPayload(currentDatasource),
});
}
const { json } = await SupersetClient.get({
endpoint: `/api/v1/dataset/${currentDatasource?.id}`,
});
})
.then(({ json }) => {
addSuccessToast(t('The dataset has been saved'));
// eslint-disable-next-line no-param-reassign
json.result.type = 'table';
onDatasourceSave({
@ -200,10 +225,9 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
owners: currentDatasource.owners,
});
onHide();
})
.catch(response => {
} catch (response) {
setIsSaving(false);
getClientErrorObject(response).then(error => {
const error = await getClientErrorObject(response);
let errorResponse: SupersetError | undefined;
let errorText: string | undefined;
// sip-40 error response
@ -224,14 +248,13 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
/>
),
});
});
});
}
};
const onDatasourceChange = (data: Record<string, any>, err: Array<any>) => {
const onDatasourceChange = (data: DatasetObject, err: Array<any>) => {
setCurrentDatasource({
...data,
metrics: data?.metrics.map((metric: Record<string, unknown>) => ({
metrics: data?.metrics.map((metric: DatasetObject['metrics'][0]) => ({
...metric,
is_certified: metric?.certified_by || metric?.certification_details,
})),

View File

@ -17,6 +17,9 @@
* under the License.
*/
import { Children, cloneElement } from 'react';
import { nanoid } from 'nanoid';
import { SupersetClient, tn } from '@superset-ui/core';
import rison from 'rison';
export function recurseReactClone(children, type, propExtender) {
/**
@ -40,3 +43,102 @@ export function recurseReactClone(children, type, propExtender) {
return newChild;
});
}
export function updateColumns(prevCols, newCols, addSuccessToast) {
// cols: Array<{column_name: string; is_dttm: boolean; type: string;}>
const databaseColumnNames = newCols.map(col => col.column_name);
const currentCols = prevCols.reduce((agg, col) => {
// eslint-disable-next-line no-param-reassign
agg[col.column_name] = col;
return agg;
}, {});
const columnChanges = {
added: [],
modified: [],
removed: prevCols
.map(col => col.column_name)
.filter(col => !databaseColumnNames.includes(col)),
finalColumns: [],
};
newCols.forEach(col => {
const currentCol = currentCols[col.column_name];
if (!currentCol) {
// new column
columnChanges.finalColumns.push({
id: nanoid(),
column_name: col.column_name,
type: col.type,
groupby: true,
filterable: true,
is_dttm: col.is_dttm,
});
columnChanges.added.push(col.column_name);
} else if (
currentCol.type !== col.type ||
currentCol.is_dttm !== col.is_dttm
) {
// modified column
columnChanges.finalColumns.push({
...currentCol,
type: col.type,
is_dttm: currentCol.is_dttm || col.is_dttm,
});
columnChanges.modified.push(col.column_name);
} else {
// unchanged
columnChanges.finalColumns.push(currentCol);
}
});
if (columnChanges.modified.length) {
addSuccessToast(
tn(
'Modified 1 column in the virtual dataset',
'Modified %s columns in the virtual dataset',
columnChanges.modified.length,
),
);
}
if (columnChanges.removed.length) {
addSuccessToast(
tn(
'Removed 1 column from the virtual dataset',
'Removed %s columns from the virtual dataset',
columnChanges.removed.length,
),
);
}
if (columnChanges.added.length) {
addSuccessToast(
tn(
'Added 1 new column to the virtual dataset',
'Added %s new columns to the virtual dataset',
columnChanges.added.length,
),
);
}
return columnChanges;
}
export async function fetchSyncedColumns(datasource) {
const params = {
datasource_type: datasource.type,
database_name:
datasource.database?.database_name || datasource.database?.name,
catalog_name: datasource.catalog,
schema_name: datasource.schema,
table_name: datasource.table_name,
normalize_columns: datasource.normalize_columns,
always_filter_main_dttm: datasource.always_filter_main_dttm,
};
Object.entries(params).forEach(([key, value]) => {
// rison can't encode the undefined value
if (value === undefined) {
params[key] = null;
}
});
const endpoint = `/datasource/external_metadata_by_name/?q=${rison.encode_uri(
params,
)}`;
const { json } = await SupersetClient.get({ endpoint });
return json;
}

View File

@ -18,13 +18,9 @@
*/
import { supersetTheme } from '@superset-ui/core';
import { Tooltip as AntdTooltip } from 'antd-v5';
import {
TooltipProps as AntdTooltipProps,
TooltipPlacement as AntdTooltipPlacement,
} from 'antd-v5/lib/tooltip';
import { TooltipProps, TooltipPlacement } from 'antd-v5/lib/tooltip';
export type TooltipPlacement = AntdTooltipPlacement;
export type TooltipProps = AntdTooltipProps;
export { TooltipProps, TooltipPlacement };
export const Tooltip = ({ overlayStyle, ...props }: TooltipProps) => (
<>

View File

@ -164,6 +164,21 @@ export function setStashFormData(
};
}
export const START_METADATA_LOADING = 'START_METADATA_LOADING';
export function startMetaDataLoading() {
return { type: START_METADATA_LOADING };
}
export const STOP_METADATA_LOADING = 'STOP_METADATA_LOADING';
export function stopMetaDataLoading() {
return { type: STOP_METADATA_LOADING };
}
export const SYNC_DATASOURCE_METADATA = 'SYNC_DATASOURCE_METADATA';
export function syncDatasourceMetadata(datasource: Dataset) {
return { type: SYNC_DATASOURCE_METADATA, datasource };
}
export const exploreActions = {
...toastActions,
fetchDatasourcesStarted,
@ -178,6 +193,7 @@ export const exploreActions = {
createNewSlice,
sliceUpdated,
setForceQuery,
syncDatasourceMetadata,
};
export type ExploreActions = typeof exploreActions;

View File

@ -19,6 +19,10 @@
import { Component } from 'react';
import PropTypes from 'prop-types';
import { TextArea } from 'src/components/Input';
import {
Tooltip,
TooltipProps as TooltipOptions,
} from 'src/components/Tooltip';
import { t, withTheme } from '@superset-ui/core';
import Button from 'src/components/Button';
@ -55,6 +59,7 @@ const propTypes = {
'vertical',
]),
textAreaStyles: PropTypes.object,
tooltipOptions: PropTypes.oneOf([null, TooltipOptions]),
};
const defaultProps = {
@ -67,6 +72,7 @@ const defaultProps = {
readOnly: false,
resize: null,
textAreaStyles: {},
tooltipOptions: {},
};
class TextAreaControl extends Component {
@ -94,8 +100,8 @@ class TextAreaControl extends Component {
if (this.props.readOnly) {
style.backgroundColor = '#f2f2f2';
}
return (
const codeEditor = (
<div>
<TextAreaEditor
mode={this.props.language}
style={style}
@ -108,9 +114,17 @@ class TextAreaControl extends Component {
{...this.props}
onChange={this.onAreaEditorChange.bind(this)}
/>
</div>
);
if (this.props.tooltipOptions) {
return <Tooltip {...this.props.tooltipOptions}>{codeEditor}</Tooltip>;
}
return (
return codeEditor;
}
const textArea = (
<div>
<TextArea
placeholder={t('textarea')}
onChange={this.onControlChange.bind(this)}
@ -118,7 +132,12 @@ class TextAreaControl extends Component {
disabled={this.props.readOnly}
style={{ height: this.props.height }}
/>
</div>
);
if (this.props.tooltipOptions) {
return <Tooltip {...this.props.tooltipOptions}>{textArea}</Tooltip>;
}
return textArea;
}
renderModalBody() {

View File

@ -50,6 +50,24 @@ export default function exploreReducer(state = {}, action) {
isDatasourceMetaLoading: true,
};
},
[actions.START_METADATA_LOADING]() {
return {
...state,
isDatasourceMetaLoading: true,
};
},
[actions.STOP_METADATA_LOADING]() {
return {
...state,
isDatasourceMetaLoading: false,
};
},
[actions.SYNC_DATASOURCE_METADATA]() {
return {
...state,
datasource: action.datasource,
};
},
[actions.UPDATE_FORM_DATA_BY_DATASOURCE]() {
const newFormData = { ...state.form_data };
const { prevDatasource, newDatasource } = action;

View File

@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useState } from 'react';
import { useRef, useState } from 'react';
import { SupersetTheme, t } from '@superset-ui/core';
import { Button, AntdSelect } from 'src/components';
import InfoTooltip from 'src/components/InfoTooltip';
@ -46,6 +46,7 @@ export const EncryptedField = ({
db,
editNewDb,
}: FieldPropTypes) => {
const selectedFileInputRef = useRef<HTMLInputElement | null>(null);
const [uploadOption, setUploadOption] = useState<number>(
CredentialInfoOptions.JsonUpload.valueOf(),
);
@ -152,9 +153,7 @@ export const EncryptedField = ({
{!fileToUpload && (
<Button
className="input-upload-btn"
onClick={() =>
document?.getElementById('selectedFile')?.click()
}
onClick={() => selectedFileInputRef.current?.click()}
>
{t('Choose File')}
</Button>
@ -178,6 +177,7 @@ export const EncryptedField = ({
)}
<input
ref={selectedFileInputRef}
id="selectedFile"
accept=".json"
className="input-upload"
@ -196,9 +196,9 @@ export const EncryptedField = ({
checked: false,
},
});
(
document.getElementById('selectedFile') as HTMLInputElement
).value = null as any;
if (selectedFileInputRef.current) {
selectedFileInputRef.current.value = null as any;
}
}}
/>
</div>

View File

@ -21,14 +21,7 @@
import fetchMock from 'fetch-mock';
import userEvent from '@testing-library/user-event';
import {
render,
screen,
within,
cleanup,
act,
waitFor,
} from 'spec/helpers/testing-library';
import { render, screen, within, waitFor } from 'spec/helpers/testing-library';
import { getExtensionsRegistry } from '@superset-ui/core';
import setupExtensions from 'src/setup/setupExtensions';
import * as hooks from 'src/views/CRUD/hooks';
@ -37,6 +30,7 @@ import DatabaseModal, {
dbReducer,
DBReducerActionType,
ActionType,
DatabaseModalProps,
} from './index';
jest.mock('@superset-ui/core', () => ({
@ -64,6 +58,18 @@ const AVAILABLE_DB_ENDPOINT = 'glob:*/api/v1/database/available*';
const VALIDATE_PARAMS_ENDPOINT = 'glob:*/api/v1/database/validate_parameters*';
const DATABASE_CONNECT_ENDPOINT = 'glob:*/api/v1/database/';
const databaseFixture: DatabaseObject = {
id: 123,
backend: 'postgres',
configuration_method: ConfigurationMethod.DynamicForm,
database_name: 'Postgres',
name: 'PostgresDB',
is_managed_externally: false,
driver: 'psycopg2',
};
describe('DatabaseModal', () => {
beforeEach(() => {
fetchMock.post(DATABASE_CONNECT_ENDPOINT, {
id: 10,
result: {
@ -77,7 +83,6 @@ fetchMock.post(DATABASE_CONNECT_ENDPOINT, {
json: 'foo',
});
fetchMock.config.overwriteRoutes = true;
fetchMock.get(DATABASE_FETCH_ENDPOINT, {
result: {
id: 10,
@ -302,41 +307,32 @@ fetchMock.mock(AVAILABLE_DB_ENDPOINT, {
fetchMock.post(VALIDATE_PARAMS_ENDPOINT, {
message: 'OK',
});
const databaseFixture: DatabaseObject = {
id: 123,
backend: 'postgres',
configuration_method: ConfigurationMethod.DynamicForm,
database_name: 'Postgres',
name: 'PostgresDB',
is_managed_externally: false,
driver: 'psycopg2',
};
describe('DatabaseModal', () => {
const renderAndWait = async () =>
waitFor(() =>
render(<DatabaseModal {...dbProps} />, {
useRedux: true,
}),
);
beforeEach(async () => {
await renderAndWait();
});
afterEach(cleanup);
beforeEach(() => {
jest.clearAllMocks();
});
afterEach(() => {
fetchMock.restore();
});
const setup = (propsOverwrite: Partial<DatabaseModalProps> = {}) =>
render(<DatabaseModal {...dbProps} {...propsOverwrite} />, {
useRedux: true,
});
describe('Visual: New database connection', () => {
test('renders the initial load of Step 1 correctly', () => {
test('renders the initial load of Step 1 correctly', async () => {
setup();
// ---------- Components ----------
// <TabHeader> - AntD header
const closeButton = screen.getByLabelText('Close');
const closeButton = await screen.findByLabelText('Close');
const step1Header = screen.getByRole('heading', {
name: /connect a database/i,
});
// <ModalHeader> - Connection header
const step1Helper = screen.getByText(/step 1 of 3/i);
const step1Helper = await screen.findByText(/step 1 of 3/i);
const selectDbHeader = screen.getByRole('heading', {
name: /select a database to connect/i,
});
@ -376,7 +372,8 @@ describe('DatabaseModal', () => {
hidden: true,
});
const footer = document.getElementsByClassName('ant-modal-footer');
const modal = screen.getByRole('dialog');
const footer = modal.querySelector('.ant-modal-footer');
// ---------- TODO (lyndsiWilliams): Selector options, can't seem to get these to render properly.
// renderAvailableSelector() => <Alert> - Supported databases alert
@ -415,13 +412,15 @@ describe('DatabaseModal', () => {
expect(component).toBeInTheDocument();
});
// there should be a footer but it should not have any buttons in it
expect(footer[0]).toBeEmptyDOMElement();
expect(footer).toBeEmptyDOMElement();
});
test('renders the "Basic" tab of SQL Alchemy form (step 2 of 2) correctly', async () => {
setup();
// On step 1, click dbButton to access SQL Alchemy form
userEvent.click(
screen.getByRole('button', {
await screen.findByRole('button', {
name: /sqlite/i,
}),
);
@ -429,7 +428,7 @@ describe('DatabaseModal', () => {
// ---------- Components ----------
// <TabHeader> - AntD header
const closeButton = screen.getByRole('button', { name: 'Close' });
const closeButton = await screen.findByRole('button', { name: 'Close' });
const basicHeader = screen.getByRole('heading', {
name: /connect a database/i,
@ -462,7 +461,7 @@ describe('DatabaseModal', () => {
// <SSHTunnelForm> - Basic tab's SSH Tunnel Form
const SSHTunnelingToggle = screen.getByTestId('ssh-tunnel-switch');
userEvent.click(SSHTunnelingToggle);
const SSHTunnelServerAddressInput = screen.getByTestId(
const SSHTunnelServerAddressInput = await screen.findByTestId(
'ssh-tunnel-server_address-input',
);
const SSHTunnelServerPortInput = screen.getByTestId(
@ -527,9 +526,11 @@ describe('DatabaseModal', () => {
});
test('renders the unexpanded "Advanced" tab correctly', async () => {
setup();
// On step 1, click dbButton to access step 2
userEvent.click(
screen.getByRole('button', {
await screen.findByRole('button', {
name: /sqlite/i,
}),
);
@ -626,18 +627,20 @@ describe('DatabaseModal', () => {
});
test('renders the "Advanced" - SQL LAB tab correctly (unexpanded)', async () => {
setup();
// ---------- Components ----------
// On step 1, click dbButton to access step 2
userEvent.click(
screen.getByRole('button', {
await screen.findByRole('button', {
name: /sqlite/i,
}),
);
// Click the "Advanced" tab
userEvent.click(screen.getByRole('tab', { name: /advanced/i }));
userEvent.click(await screen.findByRole('tab', { name: /advanced/i }));
// Click the "SQL Lab" tab
userEvent.click(
screen.getByRole('tab', {
await screen.findByRole('tab', {
name: /right sql lab adjust how this database will interact with sql lab\./i,
}),
);
@ -645,7 +648,7 @@ describe('DatabaseModal', () => {
// ----- BEGIN STEP 2 (ADVANCED - SQL LAB)
// <TabHeader> - AntD header
const closeButton = screen.getByRole('button', { name: /close/i });
const closeButton = await screen.findByRole('button', { name: /close/i });
const advancedHeader = screen.getByRole('heading', {
name: /connect a database/i,
});
@ -788,10 +791,12 @@ describe('DatabaseModal', () => {
});
test('renders the "Advanced" - PERFORMANCE tab correctly', async () => {
setup();
// ---------- Components ----------
// On step 1, click dbButton to access step 2
userEvent.click(
screen.getByRole('button', {
await screen.findByRole('button', {
name: /sqlite/i,
}),
);
@ -851,10 +856,12 @@ describe('DatabaseModal', () => {
});
test('renders the "Advanced" - SECURITY tab correctly', async () => {
setup();
// ---------- Components ----------
// On step 1, click dbButton to access step 2
userEvent.click(
screen.getByRole('button', {
await screen.findByRole('button', {
name: /sqlite/i,
}),
);
@ -935,10 +942,12 @@ describe('DatabaseModal', () => {
});
it('renders the "Advanced" - SECURITY tab correctly after selecting Allow file uploads', async () => {
setup();
// ---------- Components ----------
// On step 1, click dbButton to access step 2
userEvent.click(
screen.getByRole('button', {
await screen.findByRole('button', {
name: /sqlite/i,
}),
);
@ -1021,10 +1030,12 @@ describe('DatabaseModal', () => {
});
test('renders the "Advanced" - OTHER tab correctly', async () => {
setup();
// ---------- Components ----------
// On step 1, click dbButton to access step 2
userEvent.click(
screen.getByRole('button', {
await screen.findByRole('button', {
name: /sqlite/i,
}),
);
@ -1040,7 +1051,7 @@ describe('DatabaseModal', () => {
// ----- BEGIN STEP 2 (ADVANCED - OTHER)
// <TabHeader> - AntD header
const closeButton = screen.getByRole('button', { name: /close/i });
const closeButton = await screen.findByRole('button', { name: /close/i });
const advancedHeader = screen.getByRole('heading', {
name: /connect a database/i,
});
@ -1092,10 +1103,12 @@ describe('DatabaseModal', () => {
});
test('Dynamic form', async () => {
setup();
// ---------- Components ----------
// On step 1, click dbButton to access step 2
userEvent.click(
screen.getByRole('button', {
await screen.findByRole('button', {
name: /postgresql/i,
}),
);
@ -1107,9 +1120,11 @@ describe('DatabaseModal', () => {
describe('Functional: Create new database', () => {
test('directs databases to the appropriate form (dynamic vs. SQL Alchemy)', async () => {
setup();
// ---------- Dynamic example (3-step form)
// Click the PostgreSQL button to enter the dynamic form
const postgreSQLButton = screen.getByRole('button', {
const postgreSQLButton = await screen.findByRole('button', {
name: /postgresql/i,
});
userEvent.click(postgreSQLButton);
@ -1139,8 +1154,10 @@ describe('DatabaseModal', () => {
describe('SQL Alchemy form flow', () => {
test('enters step 2 of 2 when proper database is selected', async () => {
setup();
userEvent.click(
screen.getByRole('button', {
await screen.findByRole('button', {
name: /sqlite/i,
}),
);
@ -1167,8 +1184,10 @@ describe('DatabaseModal', () => {
describe('step 2 component interaction', () => {
test('properly interacts with textboxes', async () => {
setup();
userEvent.click(
screen.getByRole('button', {
await screen.findByRole('button', {
name: /sqlite/i,
}),
);
@ -1211,15 +1230,17 @@ describe('DatabaseModal', () => {
describe('SSH Tunnel Form interaction', () => {
test('properly interacts with SSH Tunnel form textboxes for dynamic form', async () => {
setup();
userEvent.click(
screen.getByRole('button', {
await screen.findByRole('button', {
name: /postgresql/i,
}),
);
expect(await screen.findByText(/step 2 of 3/i)).toBeInTheDocument();
const SSHTunnelingToggle = screen.getByTestId('ssh-tunnel-switch');
userEvent.click(SSHTunnelingToggle);
const SSHTunnelServerAddressInput = screen.getByTestId(
const SSHTunnelServerAddressInput = await screen.findByTestId(
'ssh-tunnel-server_address-input',
);
expect(SSHTunnelServerAddressInput).toHaveValue('');
@ -1246,8 +1267,10 @@ describe('DatabaseModal', () => {
});
test('properly interacts with SSH Tunnel form textboxes', async () => {
setup();
userEvent.click(
screen.getByRole('button', {
await screen.findByRole('button', {
name: /sqlite/i,
}),
);
@ -1255,7 +1278,7 @@ describe('DatabaseModal', () => {
expect(await screen.findByText(/step 2 of 2/i)).toBeInTheDocument();
const SSHTunnelingToggle = screen.getByTestId('ssh-tunnel-switch');
userEvent.click(SSHTunnelingToggle);
const SSHTunnelServerAddressInput = screen.getByTestId(
const SSHTunnelServerAddressInput = await screen.findByTestId(
'ssh-tunnel-server_address-input',
);
expect(SSHTunnelServerAddressInput).toHaveValue('');
@ -1282,8 +1305,10 @@ describe('DatabaseModal', () => {
});
test('if the SSH Tunneling toggle is not true, no inputs are displayed', async () => {
setup();
userEvent.click(
screen.getByRole('button', {
await screen.findByRole('button', {
name: /sqlite/i,
}),
);
@ -1310,8 +1335,10 @@ describe('DatabaseModal', () => {
});
test('If user changes the login method, the inputs change', async () => {
setup();
userEvent.click(
screen.getByRole('button', {
await screen.findByRole('button', {
name: /sqlite/i,
}),
);
@ -1319,7 +1346,7 @@ describe('DatabaseModal', () => {
expect(await screen.findByText(/step 2 of 2/i)).toBeInTheDocument();
const SSHTunnelingToggle = screen.getByTestId('ssh-tunnel-switch');
userEvent.click(SSHTunnelingToggle);
const SSHTunnelUsePasswordInput = screen.getByTestId(
const SSHTunnelUsePasswordInput = await screen.findByTestId(
'ssh-tunnel-use_password-radio',
);
expect(SSHTunnelUsePasswordInput).toBeInTheDocument();
@ -1348,6 +1375,8 @@ describe('DatabaseModal', () => {
describe('Dynamic form flow', () => {
test('enters step 2 of 3 when proper database is selected', async () => {
setup();
expect(await screen.findByText(/step 1 of 3/i)).toBeInTheDocument();
userEvent.click(
screen.getByRole('button', {
@ -1355,14 +1384,13 @@ describe('DatabaseModal', () => {
}),
);
expect(await screen.findByText(/step 2 of 3/i)).toBeInTheDocument();
const step2of3text = screen.getByText(/step 2 of 3/i);
expect(step2of3text).toBeVisible();
});
test('enters form credentials and runs fetchResource when "Connect" is clicked', async () => {
setup();
userEvent.click(
screen.getByRole('button', {
await screen.findByRole('button', {
name: /postgresql/i,
}),
);
@ -1403,9 +1431,11 @@ describe('DatabaseModal', () => {
describe('Import database flow', () => {
test('imports a file', async () => {
const importDbButton = screen.getByTestId(
setup();
const importDbButton = (await screen.findByTestId(
'import-database-btn',
) as HTMLInputElement;
)) as HTMLInputElement;
importDbButton.type = 'file';
importDbButton.files = {} as FileList;
expect(importDbButton).toBeInTheDocument();
@ -1423,54 +1453,25 @@ describe('DatabaseModal', () => {
});
describe('DatabaseModal w/ Deeplinking Engine', () => {
const renderAndWait = async () => {
const mounted = act(async () => {
render(<DatabaseModal {...dbProps} dbEngine="PostgreSQL" />, {
useRedux: true,
});
});
return mounted;
};
beforeEach(async () => {
await renderAndWait();
});
test('enters step 2 of 3 when proper database is selected', () => {
const step2of3text = screen.getByText(/step 2 of 3/i);
test('enters step 2 of 3 when proper database is selected', async () => {
setup({ dbEngine: 'PostgreSQL' });
const step2of3text = await screen.findByText(/step 2 of 3/i);
expect(step2of3text).toBeInTheDocument();
});
});
describe('DatabaseModal w/ GSheet Engine', () => {
const renderAndWait = async () => {
const dbProps = {
show: true,
database_name: 'my database',
sqlalchemy_uri: 'gsheets://',
};
const mounted = act(async () => {
render(<DatabaseModal {...dbProps} dbEngine="Google Sheets" />, {
useRedux: true,
});
});
return mounted;
};
beforeEach(async () => {
await renderAndWait();
});
it('enters step 2 of 2 when proper database is selected', () => {
const step2of2text = screen.getByText(/step 2 of 2/i);
it('enters step 2 of 2 when proper database is selected', async () => {
setup({ dbEngine: 'Google Sheets' });
const step2of2text = await screen.findByText(/step 2 of 2/i);
expect(step2of2text).toBeInTheDocument();
});
it('renders the "Advanced" - SECURITY tab without Allow File Upload Checkbox', async () => {
setup({ dbEngine: 'Google Sheets' });
// Click the "Advanced" tab
userEvent.click(screen.getByRole('tab', { name: /advanced/i }));
userEvent.click(await screen.findByRole('tab', { name: /advanced/i }));
// Click the "Security" tab
userEvent.click(
screen.getByRole('tab', {
@ -1509,6 +1510,8 @@ describe('DatabaseModal', () => {
});
it('if the SSH Tunneling toggle is not displayed, nothing should get displayed', async () => {
setup({ dbEngine: 'Google Sheets' });
const SSHTunnelingToggle = screen.queryByTestId('ssh-tunnel-switch');
expect(SSHTunnelingToggle).not.toBeInTheDocument();
const SSHTunnelServerAddressInput = screen.queryByTestId(
@ -1536,22 +1539,9 @@ describe('DatabaseModal', () => {
useSingleViewResource: jest.fn(),
}));
const renderAndWait = async () => {
const mounted = act(async () => {
render(<DatabaseModal {...dbProps} dbEngine="PostgreSQL" />, {
useRedux: true,
});
});
return mounted;
};
beforeEach(async () => {
await renderAndWait();
});
test('Error displays when it is an object', async () => {
const step2of3text = screen.getByText(/step 2 of 3/i);
setup({ dbEngine: 'PostgreSQL' });
const step2of3text = await screen.findByText(/step 2 of 3/i);
const errorSection = screen.getByText(/Database Creation Error/i);
expect(step2of3text).toBeInTheDocument();
expect(errorSection).toBeInTheDocument();
@ -1581,22 +1571,10 @@ describe('DatabaseModal', () => {
setResource: jest.fn(),
});
const renderAndWait = async () => {
const mounted = act(async () => {
render(<DatabaseModal {...dbProps} dbEngine="PostgreSQL" />, {
useRedux: true,
});
});
return mounted;
};
beforeEach(async () => {
await renderAndWait();
});
test('Error displays when it is a string', async () => {
const step2of3text = screen.getByText(/step 2 of 3/i);
setup({ dbEngine: 'PostgreSQL' });
const step2of3text = await screen.findByText(/step 2 of 3/i);
const errorTitleMessage = screen.getByText(/Database Creation Error/i);
const button = screen.getByText('See more');
userEvent.click(button);
@ -1608,7 +1586,7 @@ describe('DatabaseModal', () => {
});
describe('DatabaseModal w Extensions', () => {
const renderAndWait = async () => {
beforeAll(() => {
const extensionsRegistry = getExtensionsRegistry();
extensionsRegistry.set('ssh_tunnel.form.switch', () => (
@ -1616,23 +1594,12 @@ describe('DatabaseModal', () => {
));
setupExtensions();
const mounted = act(async () => {
render(<DatabaseModal {...dbProps} dbEngine="SQLite" />, {
useRedux: true,
});
});
return mounted;
};
beforeEach(async () => {
await renderAndWait();
});
test('should render an extension component if one is supplied', () => {
test('should render an extension component if one is supplied', async () => {
setup({ dbEngine: 'SQLite' });
expect(
screen.getByText('ssh_tunnel.form.switch extension component'),
await screen.findByText('ssh_tunnel.form.switch extension component'),
).toBeInTheDocument();
});
});

View File

@ -139,7 +139,7 @@ const SSHTunnelContainer = styled.div`
`};
`;
interface DatabaseModalProps {
export interface DatabaseModalProps {
addDangerToast: (msg: string) => void;
addSuccessToast: (msg: string) => void;
onDatabaseAdd?: (database?: DatabaseObject) => void;
@ -1335,7 +1335,7 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
useEffect(() => {
if (importingModal) {
document
.getElementsByClassName('ant-upload-list-item-name')[0]
?.getElementsByClassName('ant-upload-list-item-name')[0]
.scrollIntoView();
}
}, [importingModal]);

View File

@ -622,7 +622,7 @@ test('CSV form post', async () => {
// Select a file from the file dialog
const file = new File(['test'], 'test.csv', { type: 'text' });
const inputElement = document.querySelector('input[type="file"]');
const inputElement = screen.getByTestId('model-file-input');
if (inputElement) {
userEvent.upload(inputElement as HTMLElement, file);
@ -680,7 +680,7 @@ test('Excel form post', async () => {
// Select a file from the file dialog
const file = new File(['test'], 'test.xls', { type: 'text' });
const inputElement = document.querySelector('input[type="file"]');
const inputElement = screen.getByTestId('model-file-input');
if (inputElement) {
userEvent.upload(inputElement as HTMLElement, file);
@ -738,7 +738,7 @@ test('Columnar form post', async () => {
// Select a file from the file dialog
const file = new File(['test'], 'test.parquet', { type: 'text' });
const inputElement = document.querySelector('input[type="file"]');
const inputElement = screen.getByTestId('model-file-input');
if (inputElement) {
userEvent.upload(inputElement as HTMLElement, file);

View File

@ -1,4 +1,5 @@
import { Currency } from '@superset-ui/core';
import { Currency, type DatasourceType } from '@superset-ui/core';
import { Owner } from '@superset-ui/chart-controls';
/**
* Licensed to the Apache Software Foundation (ASF) under one
@ -32,37 +33,52 @@ export type ColumnObject = {
python_date_format?: string;
uuid?: string;
extra?: string;
certified_by?: string;
certification_details?: string;
warning_markdown?: string;
advanced_data_type?: string;
};
type MetricObject = {
id: number;
uuid: number;
expression?: string;
description?: string;
metric_name: string;
verbose_name?: string;
metric_type: string;
d3format?: string;
currency?: Currency;
warning_text?: string;
certified_by?: string;
certification_details?: string;
warning_markdown?: string;
};
export type DatasetObject = {
id: number;
table_name?: string;
sql?: string;
filter_select_enabled?: boolean;
fetch_values_predicate?: string;
schema?: string;
description?: string;
main_dttm_col?: string;
description: string | null;
main_dttm_col: string;
offset?: number;
default_endpoint?: string;
cache_timeout?: number;
is_sqllab_view?: boolean;
template_params?: string;
owners: number[];
owners: Owner[];
columns: ColumnObject[];
metrics: MetricObject[];
extra?: string;
is_managed_externally: boolean;
normalize_columns: boolean;
always_filter_main_dttm: boolean;
type: DatasourceType;
column_formats: Record<string, string>;
currency_formats: Record<string, Currency>;
datasource_name: string | null;
verbose_map: Record<string, string>;
};