From e8cd4f4cab29ab7dfc9706c0dba6920f5115a513 Mon Sep 17 00:00:00 2001
From: Elizabeth Thompson
Date: Wed, 21 Apr 2021 12:18:55 -0700
Subject: [PATCH 1/3] split db modal file
---
.../src/views/CRUD/data/database/DatabaseList.tsx | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx b/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx
index d1105e706222..fda65baa2703 100644
--- a/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx
+++ b/superset-frontend/src/views/CRUD/data/database/DatabaseList.tsx
@@ -29,8 +29,9 @@ import { Tooltip } from 'src/components/Tooltip';
import Icons from 'src/components/Icons';
import ListView, { FilterOperator, Filters } from 'src/components/ListView';
import { commonMenuData } from 'src/views/CRUD/data/common';
-import DatabaseModal from 'src/views/CRUD/data/database/DatabaseModal';
import ImportModelsModal from 'src/components/ImportModal/index';
+import DatabaseModal from './DatabaseModal';
+
import { DatabaseObject } from './types';
const PAGE_SIZE = 25;
From 95917e40eb6d833ef783a59f80734b539cf4e5da Mon Sep 17 00:00:00 2001
From: Elizabeth Thompson
Date: Tue, 27 Apr 2021 17:08:49 -0700
Subject: [PATCH 2/3] hook up available databases
---
.../database/DatabaseModal/index.test.jsx | 67 +++++++++++++++++++
.../data/database/DatabaseModal/index.tsx | 16 +++--
.../data/database/DatabaseModal/styles.ts | 4 --
3 files changed, 76 insertions(+), 11 deletions(-)
diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.test.jsx b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.test.jsx
index 1fc51e45a584..86967257dd26 100644
--- a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.test.jsx
+++ b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.test.jsx
@@ -319,5 +319,72 @@ describe('DatabaseModal', () => {
const todoText = screen.getAllByText(/todo/i);
expect(todoText[0]).toBeVisible();
});
+
+ describe('create database', () => {
+ it('should show a form when dynamic_form is selected', async () => {
+ const props = {
+ ...dbProps,
+ databaseId: null,
+ database_name: null,
+ sqlalchemy_uri: null,
+ };
+ render(, { useRedux: true });
+ // it should have the correct header text
+ const headerText = screen.getByText(/connect a database/i);
+ expect(headerText).toBeVisible();
+
+ await screen.findByText(/display name/i);
+
+ // it does not fetch any databases if no id is passed in
+ expect(fetchMock.calls().length).toEqual(0);
+
+ // todo we haven't hooked this up to load dynamically yet so
+ // we can't currently test it
+ });
+ });
+
+ describe('edit database', () => {
+ it('renders the sqlalchemy form when the sqlalchemy_form configuration method is set', async () => {
+ render(, { useRedux: true });
+
+ // it should have tabs
+ const tabs = screen.getAllByRole('tab');
+ expect(tabs.length).toEqual(2);
+ expect(tabs[0]).toHaveTextContent('Basic');
+ expect(tabs[1]).toHaveTextContent('Advanced');
+
+ // it should have the correct header text
+ const headerText = screen.getByText(/edit database/i);
+ expect(headerText).toBeVisible();
+
+ // todo add more when this form is built out
+ });
+ it('renders the dynamic form when the dynamic_form configuration method is set', async () => {
+ fetchMock.get(DATABASE_FETCH_ENDPOINT, {
+ result: {
+ id: 10,
+ database_name: 'my database',
+ expose_in_sqllab: false,
+ allow_ctas: false,
+ allow_cvas: false,
+ configuration_method: 'dynamic_form',
+ parameters: {
+ database: 'mydatabase',
+ },
+ },
+ });
+ render(, { useRedux: true });
+
+ await screen.findByText(/todo/i);
+
+ // // it should have tabs
+ const tabs = screen.getAllByRole('tab');
+ expect(tabs.length).toEqual(2);
+
+ // it should show a TODO for now
+ const todoText = screen.getAllByText(/todo/i);
+ expect(todoText[0]).toBeVisible();
+ });
+ });
});
});
diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.tsx b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.tsx
index 545cba00a7b1..57104b816dfb 100644
--- a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.tsx
+++ b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.tsx
@@ -248,14 +248,16 @@ const DatabaseModal: FunctionComponent = ({
// don't pass parameters if using the sqlalchemy uri
delete update.parameters;
}
- updateResource(db.id as number, update as DatabaseObject).then(result => {
- if (result) {
- if (onDatabaseAdd) {
- onDatabaseAdd();
- }
- onClose();
+ const result = await updateResource(
+ db.id as number,
+ update as DatabaseObject,
+ );
+ if (result) {
+ if (onDatabaseAdd) {
+ onDatabaseAdd();
}
- });
+ onClose();
+ }
} else if (db) {
// Create
const dbId = await createResource(update as DatabaseObject);
diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/styles.ts b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/styles.ts
index 8c33756962b0..4ae562996080 100644
--- a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/styles.ts
+++ b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/styles.ts
@@ -181,10 +181,6 @@ export const formStyles = (theme: SupersetTheme) => css`
font-size: ${theme.typography.sizes.s - 1}px;
margin-top: ${theme.gridUnit * 1.5}px;
}
- .ant-modal-body {
- padding-top: 0;
- margin-bottom: 0;
- }
.ant-tabs-content-holder {
overflow: auto;
max-height: 475px;
From 7cb409886ec51aa8441dbe7d57c9273c1b971947 Mon Sep 17 00:00:00 2001
From: Elizabeth Thompson
Date: Fri, 21 May 2021 10:57:08 -0700
Subject: [PATCH 3/3] use new validation component
---
.../Form/LabeledErrorBoundInput.tsx | 18 +-
.../DatabaseModal/DatabaseConnectionForm.tsx | 240 +++++++++++-------
.../database/DatabaseModal/index.test.jsx | 67 -----
.../data/database/DatabaseModal/index.tsx | 33 +--
.../data/database/DatabaseModal/styles.ts | 33 +--
.../src/views/CRUD/data/database/types.ts | 3 +-
superset-frontend/src/views/CRUD/hooks.ts | 57 +++++
superset/databases/commands/validate.py | 6 +-
superset/databases/schemas.py | 6 +
superset/db_engine_specs/base.py | 6 +-
10 files changed, 263 insertions(+), 206 deletions(-)
diff --git a/superset-frontend/src/components/Form/LabeledErrorBoundInput.tsx b/superset-frontend/src/components/Form/LabeledErrorBoundInput.tsx
index 8569b554a020..75df2bb088cb 100644
--- a/superset-frontend/src/components/Form/LabeledErrorBoundInput.tsx
+++ b/superset-frontend/src/components/Form/LabeledErrorBoundInput.tsx
@@ -25,17 +25,18 @@ import FormLabel from './FormLabel';
export interface LabeledErrorBoundInputProps {
label?: string;
validationMethods:
- | { onBlur: (value: any) => string }
- | { onChange: (value: any) => string };
+ | { onBlur: (value: any) => void }
+ | { onChange: (value: any) => void };
errorMessage: string | null;
helpText?: string;
required?: boolean;
id?: string;
+ classname?: string;
[x: string]: any;
}
const StyledInput = styled(Input)`
- margin: 8px 0;
+ margin: ${({ theme }) => `${theme.gridUnit}px 0 ${theme.gridUnit * 2}px`};
`;
const alertIconStyles = (theme: SupersetTheme, hasError: boolean) => css`
@@ -60,6 +61,12 @@ const alertIconStyles = (theme: SupersetTheme, hasError: boolean) => css`
}
}`}
`;
+const StyledFormGroup = styled('div')`
+ margin-bottom: ${({ theme }) => theme.gridUnit * 5}px;
+ .ant-form-item {
+ margin-bottom: 0;
+ }
+`;
const LabeledErrorBoundInput = ({
label,
@@ -68,9 +75,10 @@ const LabeledErrorBoundInput = ({
helpText,
required = false,
id,
+ className,
...props
}: LabeledErrorBoundInputProps) => (
- <>
+
{label}
@@ -83,7 +91,7 @@ const LabeledErrorBoundInput = ({
>
- >
+
);
export default LabeledErrorBoundInput;
diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/DatabaseConnectionForm.tsx b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/DatabaseConnectionForm.tsx
index f0542e481c12..5c7c729da589 100644
--- a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/DatabaseConnectionForm.tsx
+++ b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/DatabaseConnectionForm.tsx
@@ -17,11 +17,14 @@
* under the License.
*/
import React, { FormEvent } from 'react';
-import cx from 'classnames';
+import { SupersetTheme, JsonObject } from '@superset-ui/core';
import { InputProps } from 'antd/lib/input';
-import { FormLabel, FormItem } from 'src/components/Form';
-import { Input } from 'src/common/components';
-import { StyledFormHeader, formScrollableStyles } from './styles';
+import ValidatedInput from 'src/components/Form/LabeledErrorBoundInput';
+import {
+ StyledFormHeader,
+ formScrollableStyles,
+ validatedFormStyles,
+} from './styles';
import { DatabaseForm } from '../types';
export const FormFieldOrder = [
@@ -33,64 +36,137 @@ export const FormFieldOrder = [
'database_name',
];
-const CHANGE_METHOD = {
- onChange: 'onChange',
- onPropertiesChange: 'onPropertiesChange',
-};
+interface FieldPropTypes {
+ required: boolean;
+ changeMethods: { onParametersChange: (value: any) => string } & {
+ onChange: (value: any) => string;
+ };
+ validationErrors: JsonObject | null;
+ getValidation: () => void;
+}
+
+const hostField = ({
+ required,
+ changeMethods,
+ getValidation,
+ validationErrors,
+}: FieldPropTypes) => (
+
+);
+const portField = ({
+ required,
+ changeMethods,
+ getValidation,
+ validationErrors,
+}: FieldPropTypes) => (
+
+);
+const databaseField = ({
+ required,
+ changeMethods,
+ getValidation,
+ validationErrors,
+}: FieldPropTypes) => (
+
+);
+const usernameField = ({
+ required,
+ changeMethods,
+ getValidation,
+ validationErrors,
+}: FieldPropTypes) => (
+
+);
+const passwordField = ({
+ required,
+ changeMethods,
+ getValidation,
+ validationErrors,
+}: FieldPropTypes) => (
+
+);
+const displayField = ({
+ required,
+ changeMethods,
+ getValidation,
+ validationErrors,
+}: FieldPropTypes) => (
+
+);
const FORM_FIELD_MAP = {
- host: {
- description: 'Host',
- type: 'text',
- className: 'w-50',
- placeholder: 'e.g. 127.0.0.1',
- changeMethod: CHANGE_METHOD.onPropertiesChange,
- },
- port: {
- description: 'Port',
- type: 'text',
- className: 'w-50',
- placeholder: 'e.g. 5432',
- changeMethod: CHANGE_METHOD.onPropertiesChange,
- },
- database: {
- description: 'Database name',
- type: 'text',
- label:
- 'Copy the name of the PostgreSQL database you are trying to connect to.',
- placeholder: 'e.g. world_population',
- changeMethod: CHANGE_METHOD.onPropertiesChange,
- },
- username: {
- description: 'Username',
- type: 'text',
- placeholder: 'e.g. Analytics',
- changeMethod: CHANGE_METHOD.onPropertiesChange,
- },
- password: {
- description: 'Password',
- type: 'text',
- placeholder: 'e.g. ********',
- changeMethod: CHANGE_METHOD.onPropertiesChange,
- },
- database_name: {
- description: 'Display Name',
- type: 'text',
- label: 'Pick a nickname for this database to display as in Superset.',
- changeMethod: CHANGE_METHOD.onChange,
- },
- query: {
- additionalProperties: {},
- description: 'Additional parameters',
- type: 'object',
- changeMethod: CHANGE_METHOD.onPropertiesChange,
- },
+ host: hostField,
+ port: portField,
+ database: databaseField,
+ username: usernameField,
+ password: passwordField,
+ database_name: displayField,
};
const DatabaseConnectionForm = ({
dbModel: { name, parameters },
onParametersChange,
onChange,
+ validationErrors,
+ getValidation,
}: {
dbModel: DatabaseForm;
onParametersChange: (
@@ -99,6 +175,8 @@ const DatabaseConnectionForm = ({
onChange: (
event: FormEvent | { target: HTMLInputElement },
) => void;
+ validationErrors: JsonObject | null;
+ getValidation: () => void;
}) => (
<>
@@ -107,52 +185,30 @@ const DatabaseConnectionForm = ({
Need help? Learn more about connecting to {name}.
-
+
[
+ formScrollableStyles,
+ validatedFormStyles(theme),
+ ]}
+ >
{parameters &&
FormFieldOrder.filter(
(key: string) =>
Object.keys(parameters.properties).includes(key) ||
key === 'database_name',
- ).map(field => {
- const {
- className,
- description,
- type,
- placeholder,
- label,
- changeMethod,
- } = FORM_FIELD_MAP[field];
- const onEdit =
- changeMethod === CHANGE_METHOD.onChange
- ? onChange
- : onParametersChange;
- return (
-
-
- {description}
-
-
- {label}
-
- );
- })}
+ ).map(field =>
+ FORM_FIELD_MAP[field]({
+ required: parameters.required.includes(field),
+ changeMethods: { onParametersChange, onChange },
+ validationErrors,
+ getValidation,
+ key: field,
+ }),
+ )}
>
);
-
export const FormFieldMap = FORM_FIELD_MAP;
export default DatabaseConnectionForm;
diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.test.jsx b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.test.jsx
index 86967257dd26..1fc51e45a584 100644
--- a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.test.jsx
+++ b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.test.jsx
@@ -319,72 +319,5 @@ describe('DatabaseModal', () => {
const todoText = screen.getAllByText(/todo/i);
expect(todoText[0]).toBeVisible();
});
-
- describe('create database', () => {
- it('should show a form when dynamic_form is selected', async () => {
- const props = {
- ...dbProps,
- databaseId: null,
- database_name: null,
- sqlalchemy_uri: null,
- };
- render(
, { useRedux: true });
- // it should have the correct header text
- const headerText = screen.getByText(/connect a database/i);
- expect(headerText).toBeVisible();
-
- await screen.findByText(/display name/i);
-
- // it does not fetch any databases if no id is passed in
- expect(fetchMock.calls().length).toEqual(0);
-
- // todo we haven't hooked this up to load dynamically yet so
- // we can't currently test it
- });
- });
-
- describe('edit database', () => {
- it('renders the sqlalchemy form when the sqlalchemy_form configuration method is set', async () => {
- render(
, { useRedux: true });
-
- // it should have tabs
- const tabs = screen.getAllByRole('tab');
- expect(tabs.length).toEqual(2);
- expect(tabs[0]).toHaveTextContent('Basic');
- expect(tabs[1]).toHaveTextContent('Advanced');
-
- // it should have the correct header text
- const headerText = screen.getByText(/edit database/i);
- expect(headerText).toBeVisible();
-
- // todo add more when this form is built out
- });
- it('renders the dynamic form when the dynamic_form configuration method is set', async () => {
- fetchMock.get(DATABASE_FETCH_ENDPOINT, {
- result: {
- id: 10,
- database_name: 'my database',
- expose_in_sqllab: false,
- allow_ctas: false,
- allow_cvas: false,
- configuration_method: 'dynamic_form',
- parameters: {
- database: 'mydatabase',
- },
- },
- });
- render(
, { useRedux: true });
-
- await screen.findByText(/todo/i);
-
- // // it should have tabs
- const tabs = screen.getAllByRole('tab');
- expect(tabs.length).toEqual(2);
-
- // it should show a TODO for now
- const todoText = screen.getAllByText(/todo/i);
- expect(todoText[0]).toBeVisible();
- });
- });
});
});
diff --git a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.tsx b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.tsx
index 57104b816dfb..265a8561923c 100644
--- a/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.tsx
+++ b/superset-frontend/src/views/CRUD/data/database/DatabaseModal/index.tsx
@@ -33,6 +33,7 @@ import {
testDatabaseConnection,
useSingleViewResource,
useAvailableDatabases,
+ useDatabaseValidation,
} from 'src/views/CRUD/hooks';
import { useCommonConf } from 'src/views/CRUD/data/database/state';
import {
@@ -50,10 +51,9 @@ import {
antDModalStyles,
antDTabsStyles,
buttonLinkStyles,
- CreateHeader,
+ TabHeader,
CreateHeaderSubtitle,
CreateHeaderTitle,
- EditHeader,
EditHeaderSubtitle,
EditHeaderTitle,
formHelperStyles,
@@ -109,7 +109,7 @@ type DBReducerActionType =
| {
type: ActionType.dbSelected;
payload: {
- parameters: { engine?: string };
+ engine?: string;
configuration_method: CONFIGURATION_METHOD;
};
}
@@ -127,8 +127,6 @@ function dbReducer(
): Partial
| null {
const trimmedState = {
...(state || {}),
- database_name: state?.database_name?.trim() || '',
- sqlalchemy_uri: state?.sqlalchemy_uri || '',
};
switch (action.type) {
@@ -163,9 +161,7 @@ function dbReducer(
};
case ActionType.fetched:
return {
- parameters: {
- engine: trimmedState.parameters?.engine,
- },
+ engine: trimmedState.engine,
configuration_method: trimmedState.configuration_method,
...action.payload,
};
@@ -196,13 +192,16 @@ const DatabaseModal: FunctionComponent = ({
>(dbReducer, null);
const [tabKey, setTabKey] = useState(DEFAULT_TAB_KEY);
const [availableDbs, getAvailableDbs] = useAvailableDatabases();
+ const [validationErrors, getValidation] = useDatabaseValidation();
const [hasConnectedDb, setHasConnectedDb] = useState(false);
+ const [dbName, setDbName] = useState('');
const conf = useCommonConf();
const isEditMode = !!databaseId;
const useSqlAlchemyForm =
db?.configuration_method === CONFIGURATION_METHOD.SQLALCHEMY_URI;
const useTabLayout = isEditMode || useSqlAlchemyForm;
+
// Database fetch logic
const {
state: { loading: dbLoading, resource: dbFetched },
@@ -302,7 +301,6 @@ const DatabaseModal: FunctionComponent = ({
setDB({
type: ActionType.dbSelected,
payload: {
- parameters: {},
configuration_method: CONFIGURATION_METHOD.SQLALCHEMY_URI,
}, // todo hook this up to step 1
});
@@ -318,6 +316,9 @@ const DatabaseModal: FunctionComponent = ({
type: ActionType.fetched,
payload: dbFetched,
});
+ // keep a copy of the name separate for display purposes
+ // because it shouldn't change when the form is updated
+ setDbName(dbFetched.database_name);
}
}, [dbFetched]);
@@ -328,7 +329,7 @@ const DatabaseModal: FunctionComponent = ({
const dbModel: DatabaseForm =
availableDbs?.databases?.find(
(available: { engine: string | undefined }) =>
- available.engine === db?.parameters?.engine,
+ available.engine === db?.engine,
) || {};
const disableSave =
@@ -364,12 +365,12 @@ const DatabaseModal: FunctionComponent = ({
}
>
{isEditMode ? (
-
+
{db?.backend}
- {db?.database_name}
-
+ {dbName}
+
) : (
-
+
Enter Primary Credentials
Need help? Learn how to connect your database{' '}
@@ -382,7 +383,7 @@ const DatabaseModal: FunctionComponent = ({
.
-
+
)}
= ({
value: target.value,
})
}
+ getValidation={() => getValidation(db)}
+ validationErrors={validationErrors}
/>