This is an automated email from the ASF dual-hosted git repository.
kgabryje pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new f3e7c64de6 fix(virtual dataset sync): Sync virtual dataset columns
when changing the SQL query (#30903)
f3e7c64de6 is described below
commit f3e7c64de636a2fd287abdc04e6a7bec0fa12f53
Author: Jack <[email protected]>
AuthorDate: Fri Feb 7 11:16:44 2025 -0600
fix(virtual dataset sync): Sync virtual dataset columns when changing the
SQL query (#30903)
Co-authored-by: Kamil Gabryjelski <[email protected]>
---
.../cypress/e2e/explore/control.test.ts | 4 +-
superset-frontend/jest.config.js | 1 +
.../superset-ui-chart-controls/src/types.ts | 6 +
.../superset-ui-core/src/query/types/Metric.ts | 4 +-
.../test/BigNumber/transformProps.test.ts | 2 +-
.../src/components/Datasource/DatasourceEditor.jsx | 136 +---
.../src/components/Datasource/DatasourceModal.tsx | 277 ++++----
.../src/components/Datasource/utils.js | 102 +++
superset-frontend/src/components/Tooltip/index.tsx | 8 +-
.../src/explore/actions/exploreActions.ts | 16 +
.../components/controls/TextAreaControl.jsx | 61 +-
.../src/explore/reducers/exploreReducer.js | 18 +
.../DatabaseConnectionForm/EncryptedField.tsx | 14 +-
.../databases/DatabaseModal/index.test.tsx | 701 ++++++++++-----------
.../src/features/databases/DatabaseModal/index.tsx | 4 +-
.../UploadDataModel/UploadDataModal.test.tsx | 6 +-
superset-frontend/src/features/datasets/types.ts | 24 +-
17 files changed, 734 insertions(+), 650 deletions(-)
diff --git a/superset-frontend/cypress-base/cypress/e2e/explore/control.test.ts
b/superset-frontend/cypress-base/cypress/e2e/explore/control.test.ts
index b68d828ba8..c792a310ef 100644
--- a/superset-frontend/cypress-base/cypress/e2e/explore/control.test.ts
+++ b/superset-frontend/cypress-base/cypress/e2e/explore/control.test.ts
@@ -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();
diff --git a/superset-frontend/jest.config.js b/superset-frontend/jest.config.js
index 07553d889e..13d07aa198 100644
--- a/superset-frontend/jest.config.js
+++ b/superset-frontend/jest.config.js
@@ -75,4 +75,5 @@ module.exports = {
},
],
],
+ testTimeout: 10000,
};
diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/types.ts
b/superset-frontend/packages/superset-ui-chart-controls/src/types.ts
index a53a45de35..62ac944688 100644
--- a/superset-frontend/packages/superset-ui-chart-controls/src/types.ts
+++ b/superset-frontend/packages/superset-ui-chart-controls/src/types.ts
@@ -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 {
diff --git
a/superset-frontend/packages/superset-ui-core/src/query/types/Metric.ts
b/superset-frontend/packages/superset-ui-core/src/query/types/Metric.ts
index 227ca6e71d..229852373a 100644
--- a/superset-frontend/packages/superset-ui-core/src/query/types/Metric.ts
+++ b/superset-frontend/packages/superset-ui-core/src/query/types/Metric.ts
@@ -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;
diff --git
a/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/transformProps.test.ts
b/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/transformProps.test.ts
index 8c9ee5621c..8b0bf35525 100644
---
a/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/transformProps.test.ts
+++
b/superset-frontend/plugins/plugin-chart-echarts/test/BigNumber/transformProps.test.ts
@@ -173,7 +173,7 @@ describe('BigNumberWithTrendline', () => {
label: 'value',
metric_name: 'value',
d3format: '.2f',
- currency: `{symbol: 'USD', symbolPosition: 'prefix' }`,
+ currency: { symbol: 'USD', symbolPosition: 'prefix' },
},
],
},
diff --git a/superset-frontend/src/components/Datasource/DatasourceEditor.jsx
b/superset-frontend/src/components/Datasource/DatasourceEditor.jsx
index d30b67ad2a..fae0270828 100644
--- a/superset-frontend/src/components/Datasource/DatasourceEditor.jsx
+++ b/superset-frontend/src/components/Datasource/DatasourceEditor.jsx
@@ -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(', ')),
- );
- }
- 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.props.addSuccessToast(t('Metadata has been synced'));
- this.setState({ metadataLoading: false });
- })
- .catch(response =>
- getClientErrorObject(response).then(({ error, statusText }) => {
- this.props.addDangerToast(
- error || statusText || t('An error has occurred'),
- );
- this.setState({ metadataLoading: false });
- }),
+ try {
+ const newCols = await fetchSyncedColumns(datasource);
+ const columnChanges = updateColumns(
+ datasource.columns,
+ newCols,
+ this.props.addSuccessToast,
);
+ this.setColumns({ databaseColumns: columnChanges.finalColumns });
+ this.props.addSuccessToast(t('Metadata has been synced'));
+ this.setState({ metadataLoading: false });
+ } catch (error) {
+ const { error: clientError, statusText } =
+ await getClientErrorObject(error);
+ this.props.addDangerToast(
+ 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}
/>
}
/>
diff --git a/superset-frontend/src/components/Datasource/DatasourceModal.tsx
b/superset-frontend/src/components/Datasource/DatasourceModal.tsx
index 78483771d3..33cd820677 100644
--- a/superset-frontend/src/components/Datasource/DatasourceModal.tsx
+++ b/superset-frontend/src/components/Datasource/DatasourceModal.tsx
@@ -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,130 +116,145 @@ const DatasourceModal:
FunctionComponent<DatasourceModalProps> = ({
const [isEditing, setIsEditing] = useState<boolean>(false);
const dialog = useRef<any>(null);
const [modal, contextHolder] = Modal.useModal();
-
- const onConfirmSave = () => {
+ 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:
+ 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,
+ metric_name: metric.metric_name,
+ metric_type: metric.metric_type,
+ d3format: metric.d3format || null,
+ currency: !isDefined(metric.currency)
+ ? null
+ : JSON.stringify(metric.currency),
+ verbose_name: metric.verbose_name,
+ warning_text: metric.warning_text,
+ uuid: metric.uuid,
+ extra: buildExtraJsonObject(metric),
+ };
+ if (!Number.isNaN(Number(metric.id))) {
+ metricBody.id = metric.id;
+ }
+ return metricBody;
+ }),
+ columns: datasource?.columns?.map(
+ (column: DatasetObject['columns'][0]) => ({
+ id: typeof column.id === 'number' ? column.id : undefined,
+ column_name: column.column_name,
+ type: column.type,
+ advanced_data_type: column.advanced_data_type,
+ verbose_name: column.verbose_name,
+ description: column.description,
+ expression: column.expression,
+ filterable: column.filterable,
+ groupby: column.groupby,
+ is_active: column.is_active,
+ is_dttm: column.is_dttm,
+ python_date_format: column.python_date_format || null,
+ uuid: column.uuid,
+ extra: buildExtraJsonObject(column),
+ }),
+ ),
+ owners: datasource.owners.map(
+ (o: Record<string, number>) => o.value || o.id,
+ ),
+ });
+ const onConfirmSave = async () => {
// 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,
- 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>) => {
- const metricBody: any = {
- expression: metric.expression,
- description: metric.description,
- metric_name: metric.metric_name,
- metric_type: metric.metric_type,
- d3format: metric.d3format || null,
- currency: !isDefined(metric.currency)
- ? null
- : JSON.stringify(metric.currency),
- verbose_name: metric.verbose_name,
- warning_text: metric.warning_text,
- uuid: metric.uuid,
- extra: buildExtraJsonObject(metric),
- };
- if (!Number.isNaN(Number(metric.id))) {
- metricBody.id = metric.id;
- }
- return metricBody;
- },
- ),
- columns: currentDatasource?.columns?.map(
- (column: Record<string, unknown>) => ({
- id: typeof column.id === 'number' ? column.id : undefined,
- column_name: column.column_name,
- type: column.type,
- advanced_data_type: column.advanced_data_type,
- verbose_name: column.verbose_name,
- description: column.description,
- expression: column.expression,
- filterable: column.filterable,
- groupby: column.groupby,
- is_active: column.is_active,
- is_dttm: column.is_dttm,
- python_date_format: column.python_date_format || null,
- uuid: column.uuid,
- extra: buildExtraJsonObject(column),
- }),
- ),
- owners: currentDatasource.owners.map(
- (o: Record<string, number>) => o.value || o.id,
- ),
- },
- })
- .then(() => {
- addSuccessToast(t('The dataset has been saved'));
- return SupersetClient.get({
- endpoint: `/api/v1/dataset/${currentDatasource?.id}`,
- });
- })
- .then(({ json }) => {
- // eslint-disable-next-line no-param-reassign
- json.result.type = 'table';
- onDatasourceSave({
- ...json.result,
- owners: currentDatasource.owners,
- });
- onHide();
- })
- .catch(response => {
- setIsSaving(false);
- getClientErrorObject(response).then(error => {
- let errorResponse: SupersetError | undefined;
- let errorText: string | undefined;
- // sip-40 error response
- if (error?.errors?.length) {
- errorResponse = error.errors[0];
- } else if (typeof error.error === 'string') {
- // backward compatible with old error messages
- errorText = error.error;
- }
- modal.error({
- title: t('Error saving dataset'),
- okButtonProps: { danger: true, className: 'btn-danger' },
- content: (
- <ErrorMessageWithStackTrace
- error={errorResponse}
- source="crud"
- fallback={errorText}
- />
- ),
- });
+ 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}`,
+ });
+ addSuccessToast(t('The dataset has been saved'));
+ // eslint-disable-next-line no-param-reassign
+ json.result.type = 'table';
+ onDatasourceSave({
+ ...json.result,
+ owners: currentDatasource.owners,
+ });
+ onHide();
+ } catch (response) {
+ setIsSaving(false);
+ const error = await getClientErrorObject(response);
+ let errorResponse: SupersetError | undefined;
+ let errorText: string | undefined;
+ // sip-40 error response
+ if (error?.errors?.length) {
+ errorResponse = error.errors[0];
+ } else if (typeof error.error === 'string') {
+ // backward compatible with old error messages
+ errorText = error.error;
+ }
+ modal.error({
+ title: t('Error saving dataset'),
+ okButtonProps: { danger: true, className: 'btn-danger' },
+ content: (
+ <ErrorMessageWithStackTrace
+ error={errorResponse}
+ source="crud"
+ fallback={errorText}
+ />
+ ),
});
+ }
};
- 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,
})),
diff --git a/superset-frontend/src/components/Datasource/utils.js
b/superset-frontend/src/components/Datasource/utils.js
index ccdb1b414a..001a1a30b7 100644
--- a/superset-frontend/src/components/Datasource/utils.js
+++ b/superset-frontend/src/components/Datasource/utils.js
@@ -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;
+}
diff --git a/superset-frontend/src/components/Tooltip/index.tsx
b/superset-frontend/src/components/Tooltip/index.tsx
index de5eef4b40..252aac3402 100644
--- a/superset-frontend/src/components/Tooltip/index.tsx
+++ b/superset-frontend/src/components/Tooltip/index.tsx
@@ -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) => (
<>
diff --git a/superset-frontend/src/explore/actions/exploreActions.ts
b/superset-frontend/src/explore/actions/exploreActions.ts
index da702ac16f..25b03ec9f2 100644
--- a/superset-frontend/src/explore/actions/exploreActions.ts
+++ b/superset-frontend/src/explore/actions/exploreActions.ts
@@ -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;
diff --git
a/superset-frontend/src/explore/components/controls/TextAreaControl.jsx
b/superset-frontend/src/explore/components/controls/TextAreaControl.jsx
index fc0545d8c4..e8f165c8e1 100644
--- a/superset-frontend/src/explore/components/controls/TextAreaControl.jsx
+++ b/superset-frontend/src/explore/components/controls/TextAreaControl.jsx
@@ -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,31 +100,44 @@ class TextAreaControl extends Component {
if (this.props.readOnly) {
style.backgroundColor = '#f2f2f2';
}
+ const codeEditor = (
+ <div>
+ <TextAreaEditor
+ mode={this.props.language}
+ style={style}
+ minLines={minLines}
+ maxLines={inModal ? 1000 : this.props.maxLines}
+ editorProps={{ $blockScrolling: true }}
+ defaultValue={this.props.initialValue}
+ readOnly={this.props.readOnly}
+ key={this.props.name}
+ {...this.props}
+ onChange={this.onAreaEditorChange.bind(this)}
+ />
+ </div>
+ );
+
+ if (this.props.tooltipOptions) {
+ return <Tooltip {...this.props.tooltipOptions}>{codeEditor}</Tooltip>;
+ }
+ return codeEditor;
+ }
- return (
- <TextAreaEditor
- mode={this.props.language}
- style={style}
- minLines={minLines}
- maxLines={inModal ? 1000 : this.props.maxLines}
- editorProps={{ $blockScrolling: true }}
+ const textArea = (
+ <div>
+ <TextArea
+ placeholder={t('textarea')}
+ onChange={this.onControlChange.bind(this)}
defaultValue={this.props.initialValue}
- readOnly={this.props.readOnly}
- key={this.props.name}
- {...this.props}
- onChange={this.onAreaEditorChange.bind(this)}
+ disabled={this.props.readOnly}
+ style={{ height: this.props.height }}
/>
- );
- }
- return (
- <TextArea
- placeholder={t('textarea')}
- onChange={this.onControlChange.bind(this)}
- defaultValue={this.props.initialValue}
- disabled={this.props.readOnly}
- style={{ height: this.props.height }}
- />
+ </div>
);
+ if (this.props.tooltipOptions) {
+ return <Tooltip {...this.props.tooltipOptions}>{textArea}</Tooltip>;
+ }
+ return textArea;
}
renderModalBody() {
diff --git a/superset-frontend/src/explore/reducers/exploreReducer.js
b/superset-frontend/src/explore/reducers/exploreReducer.js
index 377a66d5b5..2677c1e3ff 100644
--- a/superset-frontend/src/explore/reducers/exploreReducer.js
+++ b/superset-frontend/src/explore/reducers/exploreReducer.js
@@ -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;
diff --git
a/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/EncryptedField.tsx
b/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/EncryptedField.tsx
index 147b983c45..ad80699802 100644
---
a/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/EncryptedField.tsx
+++
b/superset-frontend/src/features/databases/DatabaseModal/DatabaseConnectionForm/EncryptedField.tsx
@@ -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>
diff --git
a/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx
b/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx
index b6b3e208bd..5c109a1c4e 100644
--- a/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx
+++ b/superset-frontend/src/features/databases/DatabaseModal/index.test.tsx
@@ -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,279 +58,281 @@ 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/';
-fetchMock.post(DATABASE_CONNECT_ENDPOINT, {
- id: 10,
- result: {
- configuration_method: 'sqlalchemy_form',
- database_name: 'Other2',
- driver: 'apsw',
- expose_in_sqllab: true,
- extra: '{"allows_virtual_table_explore":true}',
- sqlalchemy_uri: 'gsheets://',
- },
- json: 'foo',
-});
+const databaseFixture: DatabaseObject = {
+ id: 123,
+ backend: 'postgres',
+ configuration_method: ConfigurationMethod.DynamicForm,
+ database_name: 'Postgres',
+ name: 'PostgresDB',
+ is_managed_externally: false,
+ driver: 'psycopg2',
+};
-fetchMock.config.overwriteRoutes = true;
-fetchMock.get(DATABASE_FETCH_ENDPOINT, {
- result: {
- id: 10,
- database_name: 'my database',
- expose_in_sqllab: false,
- allow_ctas: false,
- allow_cvas: false,
- configuration_method: 'sqlalchemy_form',
- },
-});
-fetchMock.mock(AVAILABLE_DB_ENDPOINT, {
- databases: [
- {
- available_drivers: ['psycopg2'],
- default_driver: 'psycopg2',
- engine: 'postgresql',
- name: 'PostgreSQL',
- parameters: {
- properties: {
- database: {
- description: 'Database name',
- type: 'string',
- },
- encryption: {
- description: 'Use an encrypted connection to the database',
- type: 'boolean',
- },
- host: {
- description: 'Hostname or IP address',
- type: 'string',
- },
- password: {
- description: 'Password',
- nullable: true,
- type: 'string',
- },
- port: {
- description: 'Database port',
- format: 'int32',
- maximum: 65536,
- minimum: 0,
- type: 'integer',
- },
- query: {
- additionalProperties: {},
- description: 'Additional parameters',
- type: 'object',
- },
- ssh: {
- description: 'Create SSH Tunnel',
- type: 'boolean',
- },
- username: {
- description: 'Username',
- nullable: true,
- type: 'string',
- },
- },
- required: ['database', 'host', 'port', 'username'],
- type: 'object',
- },
- preferred: true,
- sqlalchemy_uri_placeholder:
- 'postgresql://user:password@host:port/dbname[?key=value&key=value...]',
- engine_information: {
- supports_file_upload: true,
- disable_ssh_tunneling: false,
+describe('DatabaseModal', () => {
+ beforeEach(() => {
+ fetchMock.post(DATABASE_CONNECT_ENDPOINT, {
+ id: 10,
+ result: {
+ configuration_method: 'sqlalchemy_form',
+ database_name: 'Other2',
+ driver: 'apsw',
+ expose_in_sqllab: true,
+ extra: '{"allows_virtual_table_explore":true}',
+ sqlalchemy_uri: 'gsheets://',
},
- },
- {
- available_drivers: ['rest'],
- engine: 'presto',
- name: 'Presto',
- preferred: true,
- engine_information: {
- supports_file_upload: true,
- disable_ssh_tunneling: false,
+ json: 'foo',
+ });
+
+ fetchMock.get(DATABASE_FETCH_ENDPOINT, {
+ result: {
+ id: 10,
+ database_name: 'my database',
+ expose_in_sqllab: false,
+ allow_ctas: false,
+ allow_cvas: false,
+ configuration_method: 'sqlalchemy_form',
},
- },
- {
- available_drivers: ['mysqldb'],
- default_driver: 'mysqldb',
- engine: 'mysql',
- name: 'MySQL',
- parameters: {
- properties: {
- database: {
- description: 'Database name',
- type: 'string',
- },
- encryption: {
- description: 'Use an encrypted connection to the database',
- type: 'boolean',
- },
- host: {
- description: 'Hostname or IP address',
- type: 'string',
+ });
+ fetchMock.mock(AVAILABLE_DB_ENDPOINT, {
+ databases: [
+ {
+ available_drivers: ['psycopg2'],
+ default_driver: 'psycopg2',
+ engine: 'postgresql',
+ name: 'PostgreSQL',
+ parameters: {
+ properties: {
+ database: {
+ description: 'Database name',
+ type: 'string',
+ },
+ encryption: {
+ description: 'Use an encrypted connection to the database',
+ type: 'boolean',
+ },
+ host: {
+ description: 'Hostname or IP address',
+ type: 'string',
+ },
+ password: {
+ description: 'Password',
+ nullable: true,
+ type: 'string',
+ },
+ port: {
+ description: 'Database port',
+ format: 'int32',
+ maximum: 65536,
+ minimum: 0,
+ type: 'integer',
+ },
+ query: {
+ additionalProperties: {},
+ description: 'Additional parameters',
+ type: 'object',
+ },
+ ssh: {
+ description: 'Create SSH Tunnel',
+ type: 'boolean',
+ },
+ username: {
+ description: 'Username',
+ nullable: true,
+ type: 'string',
+ },
+ },
+ required: ['database', 'host', 'port', 'username'],
+ type: 'object',
},
- password: {
- description: 'Password',
- nullable: true,
- type: 'string',
+ preferred: true,
+ sqlalchemy_uri_placeholder:
+
'postgresql://user:password@host:port/dbname[?key=value&key=value...]',
+ engine_information: {
+ supports_file_upload: true,
+ disable_ssh_tunneling: false,
},
- port: {
- description: 'Database port',
- format: 'int32',
- maximum: 65536,
- minimum: 0,
- type: 'integer',
+ },
+ {
+ available_drivers: ['rest'],
+ engine: 'presto',
+ name: 'Presto',
+ preferred: true,
+ engine_information: {
+ supports_file_upload: true,
+ disable_ssh_tunneling: false,
},
- query: {
- additionalProperties: {},
- description: 'Additional parameters',
+ },
+ {
+ available_drivers: ['mysqldb'],
+ default_driver: 'mysqldb',
+ engine: 'mysql',
+ name: 'MySQL',
+ parameters: {
+ properties: {
+ database: {
+ description: 'Database name',
+ type: 'string',
+ },
+ encryption: {
+ description: 'Use an encrypted connection to the database',
+ type: 'boolean',
+ },
+ host: {
+ description: 'Hostname or IP address',
+ type: 'string',
+ },
+ password: {
+ description: 'Password',
+ nullable: true,
+ type: 'string',
+ },
+ port: {
+ description: 'Database port',
+ format: 'int32',
+ maximum: 65536,
+ minimum: 0,
+ type: 'integer',
+ },
+ query: {
+ additionalProperties: {},
+ description: 'Additional parameters',
+ type: 'object',
+ },
+ username: {
+ description: 'Username',
+ nullable: true,
+ type: 'string',
+ },
+ },
+ required: ['database', 'host', 'port', 'username'],
type: 'object',
},
- username: {
- description: 'Username',
- nullable: true,
- type: 'string',
+ preferred: true,
+ sqlalchemy_uri_placeholder:
+ 'mysql://user:password@host:port/dbname[?key=value&key=value...]',
+ engine_information: {
+ supports_file_upload: true,
+ disable_ssh_tunneling: false,
},
},
- required: ['database', 'host', 'port', 'username'],
- type: 'object',
- },
- preferred: true,
- sqlalchemy_uri_placeholder:
- 'mysql://user:password@host:port/dbname[?key=value&key=value...]',
- engine_information: {
- supports_file_upload: true,
- disable_ssh_tunneling: false,
- },
- },
- {
- available_drivers: ['pysqlite'],
- engine: 'sqlite',
- name: 'SQLite',
- preferred: true,
- engine_information: {
- supports_file_upload: true,
- disable_ssh_tunneling: false,
- },
- },
- {
- available_drivers: ['rest'],
- engine: 'druid',
- name: 'Apache Druid',
- preferred: false,
- engine_information: {
- supports_file_upload: true,
- disable_ssh_tunneling: false,
- },
- },
- {
- available_drivers: ['bigquery'],
- default_driver: 'bigquery',
- engine: 'bigquery',
- name: 'Google BigQuery',
- parameters: {
- properties: {
- credentials_info: {
- description: 'Contents of BigQuery JSON credentials.',
- type: 'string',
- 'x-encrypted-extra': true,
- },
- query: {
- type: 'object',
+ {
+ available_drivers: ['pysqlite'],
+ engine: 'sqlite',
+ name: 'SQLite',
+ preferred: true,
+ engine_information: {
+ supports_file_upload: true,
+ disable_ssh_tunneling: false,
},
},
- type: 'object',
- },
- preferred: false,
- sqlalchemy_uri_placeholder: 'bigquery://{project_id}',
- engine_information: {
- supports_file_upload: true,
- disable_ssh_tunneling: true,
- },
- },
- {
- available_drivers: ['rest'],
- default_driver: 'apsw',
- engine: 'gsheets',
- name: 'Google Sheets',
- preferred: false,
- engine_information: {
- supports_file_upload: false,
- disable_ssh_tunneling: true,
- },
- },
- {
- available_drivers: ['connector'],
- default_driver: 'connector',
- engine: 'databricks',
- name: 'Databricks',
- parameters: {
- properties: {
- access_token: {
- type: 'string',
+ {
+ available_drivers: ['rest'],
+ engine: 'druid',
+ name: 'Apache Druid',
+ preferred: false,
+ engine_information: {
+ supports_file_upload: true,
+ disable_ssh_tunneling: false,
},
- database: {
- type: 'string',
+ },
+ {
+ available_drivers: ['bigquery'],
+ default_driver: 'bigquery',
+ engine: 'bigquery',
+ name: 'Google BigQuery',
+ parameters: {
+ properties: {
+ credentials_info: {
+ description: 'Contents of BigQuery JSON credentials.',
+ type: 'string',
+ 'x-encrypted-extra': true,
+ },
+ query: {
+ type: 'object',
+ },
+ },
+ type: 'object',
},
- host: {
- type: 'string',
+ preferred: false,
+ sqlalchemy_uri_placeholder: 'bigquery://{project_id}',
+ engine_information: {
+ supports_file_upload: true,
+ disable_ssh_tunneling: true,
},
- http_path: {
- type: 'string',
+ },
+ {
+ available_drivers: ['rest'],
+ default_driver: 'apsw',
+ engine: 'gsheets',
+ name: 'Google Sheets',
+ preferred: false,
+ engine_information: {
+ supports_file_upload: false,
+ disable_ssh_tunneling: true,
},
- port: {
- format: 'int32',
- type: 'integer',
+ },
+ {
+ available_drivers: ['connector'],
+ default_driver: 'connector',
+ engine: 'databricks',
+ name: 'Databricks',
+ parameters: {
+ properties: {
+ access_token: {
+ type: 'string',
+ },
+ database: {
+ type: 'string',
+ },
+ host: {
+ type: 'string',
+ },
+ http_path: {
+ type: 'string',
+ },
+ port: {
+ format: 'int32',
+ type: 'integer',
+ },
+ },
+ required: ['access_token', 'database', 'host', 'http_path',
'port'],
+ type: 'object',
},
+ preferred: true,
+ sqlalchemy_uri_placeholder:
+
'databricks+connector://token:{access_token}@{host}:{port}/{database_name}',
},
- required: ['access_token', 'database', 'host', 'http_path', 'port'],
- type: 'object',
- },
- preferred: true,
- sqlalchemy_uri_placeholder:
-
'databricks+connector://token:{access_token}@{host}:{port}/{database_name}',
- },
- ],
-});
-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,
- }),
- );
+ ],
+ });
+ fetchMock.post(VALIDATE_PARAMS_ENDPOINT, {
+ message: 'OK',
+ });
+ });
- beforeEach(async () => {
- await renderAndWait();
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+ afterEach(() => {
+ fetchMock.restore();
});
- afterEach(cleanup);
+ 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();
});
});
diff --git a/superset-frontend/src/features/databases/DatabaseModal/index.tsx
b/superset-frontend/src/features/databases/DatabaseModal/index.tsx
index 10ae7a82cc..3a7eb0d21d 100644
--- a/superset-frontend/src/features/databases/DatabaseModal/index.tsx
+++ b/superset-frontend/src/features/databases/DatabaseModal/index.tsx
@@ -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]);
diff --git
a/superset-frontend/src/features/databases/UploadDataModel/UploadDataModal.test.tsx
b/superset-frontend/src/features/databases/UploadDataModel/UploadDataModal.test.tsx
index 5a3958e00a..34bebac917 100644
---
a/superset-frontend/src/features/databases/UploadDataModel/UploadDataModal.test.tsx
+++
b/superset-frontend/src/features/databases/UploadDataModel/UploadDataModal.test.tsx
@@ -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);
diff --git a/superset-frontend/src/features/datasets/types.ts
b/superset-frontend/src/features/datasets/types.ts
index 6ac19a7649..e0afb07670 100644
--- a/superset-frontend/src/features/datasets/types.ts
+++ b/superset-frontend/src/features/datasets/types.ts
@@ -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>;
};