feat: DBC UI Snowflake Form (#16856)

* update snowflake spec for dyanmic form

* feat(snowflake): Snowflake dynamic form (#16861)

* snowflake frontend

* snowflake frontend

* refactor(DB Connections): Build Snowflake Dynamic Form (#16875)

* moved all non-BasicParameters into own field

* refactored DB Connection Form made ValidatedInput

* quick fix

* fixed ValidatedInputField (#16934)

* add logic to check for required fields (#17022)

* fix

* fix linting

* remove host

* Update EncryptedField.tsx

Co-authored-by: AAfghahi <48933336+AAfghahi@users.noreply.github.com>
This commit is contained in:
Hugh A. Miles II 2021-10-16 20:46:06 -04:00 committed by GitHub
parent 9e6d5fc775
commit 40b88f04f6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 863 additions and 602 deletions

View File

@ -1,599 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { FormEvent, useState } from 'react';
import { SupersetTheme, JsonObject, t } from '@superset-ui/core';
import { InputProps } from 'antd/lib/input';
import { Switch, Select, Button } from 'src/common/components';
import InfoTooltip from 'src/components/InfoTooltip';
import ValidatedInput from 'src/components/Form/LabeledErrorBoundInput';
import FormLabel from 'src/components/Form/FormLabel';
import { DeleteFilled, CloseOutlined } from '@ant-design/icons';
import {
formScrollableStyles,
validatedFormStyles,
CredentialInfoForm,
toggleStyle,
infoTooltip,
StyledFooterButton,
StyledCatalogTable,
labelMarginBotton,
} from './styles';
import { CatalogObject, DatabaseForm, DatabaseObject } from '../types';
// These are the columns that are going to be added to encrypted extra, they differ in name based
// on the engine, however we want to use the same component for each of them. Make sure to add the
// the engine specific name here.
export const encryptedCredentialsMap = {
gsheets: 'service_account_info',
bigquery: 'credentials_info',
};
enum CredentialInfoOptions {
jsonUpload,
copyPaste,
}
const castStringToBoolean = (optionValue: string) => optionValue === 'true';
export const FormFieldOrder = [
'host',
'port',
'database',
'username',
'password',
'database_name',
'credentials_info',
'service_account_info',
'catalog',
'query',
'encryption',
];
interface FieldPropTypes {
required: boolean;
hasTooltip?: boolean;
tooltipText?: (value: any) => string;
onParametersChange: (value: any) => string;
onParametersUploadFileChange: (value: any) => string;
changeMethods: { onParametersChange: (value: any) => string } & {
onChange: (value: any) => string;
} & {
onQueryChange: (value: any) => string;
} & { onParametersUploadFileChange: (value: any) => string } & {
onAddTableCatalog: () => void;
onRemoveTableCatalog: (idx: number) => void;
};
validationErrors: JsonObject | null;
getValidation: () => void;
db?: DatabaseObject;
isEditMode?: boolean;
sslForced?: boolean;
defaultDBName?: string;
editNewDb?: boolean;
}
const CredentialsInfo = ({
changeMethods,
isEditMode,
db,
editNewDb,
}: FieldPropTypes) => {
const [uploadOption, setUploadOption] = useState<number>(
CredentialInfoOptions.jsonUpload.valueOf(),
);
const [fileToUpload, setFileToUpload] = useState<string | null | undefined>(
null,
);
const [isPublic, setIsPublic] = useState<boolean>(true);
const showCredentialsInfo =
db?.engine === 'gsheets' ? !isEditMode && !isPublic : !isEditMode;
// a database that has an optional encrypted field has an encrypted_extra that is an empty object, this checks for that.
const isEncrypted = isEditMode && db?.encrypted_extra !== '{}';
const encryptedField = db?.engine && encryptedCredentialsMap[db.engine];
const encryptedValue =
typeof db?.parameters?.[encryptedField] === 'object'
? JSON.stringify(db?.parameters?.[encryptedField])
: db?.parameters?.[encryptedField];
return (
<CredentialInfoForm>
{db?.engine === 'gsheets' && (
<div className="catalog-type-select">
<FormLabel
css={(theme: SupersetTheme) => labelMarginBotton(theme)}
required
>
{t('Type of Google Sheets allowed')}
</FormLabel>
<Select
style={{ width: '100%' }}
defaultValue={isEncrypted ? 'false' : 'true'}
onChange={(value: string) =>
setIsPublic(castStringToBoolean(value))
}
>
<Select.Option value="true" key={1}>
{t('Publicly shared sheets only')}
</Select.Option>
<Select.Option value="false" key={2}>
{t('Public and privately shared sheets')}
</Select.Option>
</Select>
</div>
)}
{showCredentialsInfo && (
<>
<FormLabel required>
{t('How∂ do you want to enter service account credentials?')}
</FormLabel>
<Select
defaultValue={uploadOption}
style={{ width: '100%' }}
onChange={option => setUploadOption(option)}
>
<Select.Option value={CredentialInfoOptions.jsonUpload}>
{t('Upload JSON file')}
</Select.Option>
<Select.Option value={CredentialInfoOptions.copyPaste}>
{t('Copy and Paste JSON credentials')}
</Select.Option>
</Select>
</>
)}
{uploadOption === CredentialInfoOptions.copyPaste ||
isEditMode ||
editNewDb ? (
<div className="input-container">
<FormLabel required>{t('Service Account')}</FormLabel>
<textarea
className="input-form"
name={encryptedField}
value={encryptedValue}
onChange={changeMethods.onParametersChange}
placeholder="Paste content of service credentials JSON file here"
/>
<span className="label-paste">
{t('Copy and paste the entire service account .json file here')}
</span>
</div>
) : (
showCredentialsInfo && (
<div
className="input-container"
css={(theme: SupersetTheme) => infoTooltip(theme)}
>
<div css={{ display: 'flex', alignItems: 'center' }}>
<FormLabel required>{t('Upload Credentials')}</FormLabel>
<InfoTooltip
tooltip={t(
'Use the JSON file you automatically downloaded when creating your service account.',
)}
viewBox="0 0 24 24"
/>
</div>
{!fileToUpload && (
<Button
className="input-upload-btn"
onClick={() =>
document?.getElementById('selectedFile')?.click()
}
>
{t('Choose File')}
</Button>
)}
{fileToUpload && (
<div className="input-upload-current">
{fileToUpload}
<DeleteFilled
onClick={() => {
setFileToUpload(null);
changeMethods.onParametersChange({
target: {
name: encryptedField,
value: '',
},
});
}}
/>
</div>
)}
<input
id="selectedFile"
className="input-upload"
type="file"
onChange={async event => {
let file;
if (event.target.files) {
file = event.target.files[0];
}
setFileToUpload(file?.name);
changeMethods.onParametersChange({
target: {
type: null,
name: encryptedField,
value: await file?.text(),
checked: false,
},
});
(document.getElementById(
'selectedFile',
) as HTMLInputElement).value = null as any;
}}
/>
</div>
)
)}
</CredentialInfoForm>
);
};
const TableCatalog = ({
required,
changeMethods,
getValidation,
validationErrors,
db,
}: FieldPropTypes) => {
const tableCatalog = db?.catalog || [];
const catalogError = validationErrors || {};
return (
<StyledCatalogTable>
<h4 className="gsheet-title">
{t('Connect Google Sheets as tables to this database')}
</h4>
<div>
{tableCatalog?.map((sheet: CatalogObject, idx: number) => (
<>
<FormLabel className="catalog-label" required>
{t('Google Sheet Name and URL')}
</FormLabel>
<div className="catalog-name">
<ValidatedInput
className="catalog-name-input"
required={required}
validationMethods={{ onBlur: getValidation }}
errorMessage={catalogError[idx]?.name}
placeholder={t('Enter a name for this sheet')}
onChange={(e: { target: { value: any } }) => {
changeMethods.onParametersChange({
target: {
type: `catalog-${idx}`,
name: 'name',
value: e.target.value,
},
});
}}
value={sheet.name}
/>
{tableCatalog?.length > 1 && (
<CloseOutlined
className="catalog-delete"
onClick={() => changeMethods.onRemoveTableCatalog(idx)}
/>
)}
</div>
<ValidatedInput
className="catalog-name-url"
required={required}
validationMethods={{ onBlur: getValidation }}
errorMessage={catalogError[idx]?.url}
placeholder={t('Paste the shareable Google Sheet URL here')}
onChange={(e: { target: { value: any } }) =>
changeMethods.onParametersChange({
target: {
type: `catalog-${idx}`,
name: 'value',
value: e.target.value,
},
})
}
value={sheet.value}
/>
</>
))}
<StyledFooterButton
className="catalog-add-btn"
onClick={() => {
changeMethods.onAddTableCatalog();
}}
>
+ {t('Add sheet')}
</StyledFooterButton>
</div>
</StyledCatalogTable>
);
};
const hostField = ({
required,
changeMethods,
getValidation,
validationErrors,
db,
}: FieldPropTypes) => (
<ValidatedInput
id="host"
name="host"
value={db?.parameters?.host}
required={required}
hasTooltip
tooltipText={t(
'This can be either an IP address (e.g. 127.0.0.1) or a domain name (e.g. mydatabase.com).',
)}
validationMethods={{ onBlur: getValidation }}
errorMessage={validationErrors?.host}
placeholder="e.g. 127.0.0.1"
className="form-group-w-50"
label="Host"
onChange={changeMethods.onParametersChange}
/>
);
const portField = ({
required,
changeMethods,
getValidation,
validationErrors,
db,
}: FieldPropTypes) => (
<>
<ValidatedInput
id="port"
name="port"
type="number"
required={required}
value={db?.parameters?.port as number}
validationMethods={{ onBlur: getValidation }}
errorMessage={validationErrors?.port}
placeholder="e.g. 5432"
className="form-group-w-50"
label="Port"
onChange={changeMethods.onParametersChange}
/>
</>
);
const databaseField = ({
required,
changeMethods,
getValidation,
validationErrors,
db,
}: FieldPropTypes) => (
<ValidatedInput
id="database"
name="database"
required={required}
value={db?.parameters?.database}
validationMethods={{ onBlur: getValidation }}
errorMessage={validationErrors?.database}
placeholder="e.g. world_population"
label="Database name"
onChange={changeMethods.onParametersChange}
helpText={t('Copy the name of the database you are trying to connect to.')}
/>
);
const usernameField = ({
required,
changeMethods,
getValidation,
validationErrors,
db,
}: FieldPropTypes) => (
<ValidatedInput
id="username"
name="username"
required={required}
value={db?.parameters?.username}
validationMethods={{ onBlur: getValidation }}
errorMessage={validationErrors?.username}
placeholder="e.g. Analytics"
label="Username"
onChange={changeMethods.onParametersChange}
/>
);
const passwordField = ({
required,
changeMethods,
getValidation,
validationErrors,
db,
isEditMode,
}: FieldPropTypes) => (
<ValidatedInput
id="password"
name="password"
required={required}
type={isEditMode && 'password'}
value={db?.parameters?.password}
validationMethods={{ onBlur: getValidation }}
errorMessage={validationErrors?.password}
placeholder="e.g. ********"
label="Password"
onChange={changeMethods.onParametersChange}
/>
);
const displayField = ({
changeMethods,
getValidation,
validationErrors,
db,
}: FieldPropTypes) => (
<>
<ValidatedInput
id="database_name"
name="database_name"
required
value={db?.database_name}
validationMethods={{ onBlur: getValidation }}
errorMessage={validationErrors?.database_name}
placeholder=""
label={t('Display Name')}
onChange={changeMethods.onChange}
helpText={t(
'Pick a nickname for this database to display as in Superset.',
)}
/>
</>
);
const queryField = ({
required,
changeMethods,
getValidation,
validationErrors,
db,
}: FieldPropTypes) => (
<ValidatedInput
id="query_input"
name="query_input"
required={required}
value={db?.query_input || ''}
validationMethods={{ onBlur: getValidation }}
errorMessage={validationErrors?.query}
placeholder="e.g. param1=value1&param2=value2"
label="Additional Parameters"
onChange={changeMethods.onQueryChange}
helpText={t('Add additional custom parameters')}
/>
);
const forceSSLField = ({
isEditMode,
changeMethods,
db,
sslForced,
}: FieldPropTypes) => (
<div css={(theme: SupersetTheme) => infoTooltip(theme)}>
<Switch
disabled={sslForced && !isEditMode}
checked={db?.parameters?.encryption || sslForced}
onChange={changed => {
changeMethods.onParametersChange({
target: {
type: 'toggle',
name: 'encryption',
checked: true,
value: changed,
},
});
}}
/>
<span css={toggleStyle}>SSL</span>
<InfoTooltip
tooltip={t(
'Enable SSL for increased security. Refer to the official documentation for more information.',
)}
placement="right"
viewBox="0 -5 24 24"
/>
</div>
);
const FORM_FIELD_MAP = {
host: hostField,
port: portField,
database: databaseField,
username: usernameField,
password: passwordField,
database_name: displayField,
query: queryField,
encryption: forceSSLField,
credentials_info: CredentialsInfo,
service_account_info: CredentialsInfo,
catalog: TableCatalog,
};
const DatabaseConnectionForm = ({
dbModel: { parameters },
onParametersChange,
onChange,
onQueryChange,
onParametersUploadFileChange,
onAddTableCatalog,
onRemoveTableCatalog,
validationErrors,
getValidation,
db,
isEditMode = false,
sslForced,
editNewDb,
}: {
isEditMode?: boolean;
sslForced: boolean;
editNewDb?: boolean;
dbModel: DatabaseForm;
db: Partial<DatabaseObject> | null;
onParametersChange: (
event: FormEvent<InputProps> | { target: HTMLInputElement },
) => void;
onChange: (
event: FormEvent<InputProps> | { target: HTMLInputElement },
) => void;
onQueryChange: (
event: FormEvent<InputProps> | { target: HTMLInputElement },
) => void;
onParametersUploadFileChange?: (
event: FormEvent<InputProps> | { target: HTMLInputElement },
) => void;
onAddTableCatalog: () => void;
onRemoveTableCatalog: (idx: number) => void;
validationErrors: JsonObject | null;
getValidation: () => void;
}) => (
<>
<div
// @ts-ignore
css={(theme: SupersetTheme) => [
formScrollableStyles,
validatedFormStyles(theme),
]}
>
{parameters &&
FormFieldOrder.filter(
(key: string) =>
Object.keys(parameters.properties).includes(key) ||
key === 'database_name',
).map(field =>
FORM_FIELD_MAP[field]({
required: parameters.required?.includes(field),
changeMethods: {
onParametersChange,
onChange,
onQueryChange,
onParametersUploadFileChange,
onAddTableCatalog,
onRemoveTableCatalog,
},
validationErrors,
getValidation,
db,
key: field,
isEditMode,
sslForced,
editNewDb,
}),
)}
</div>
</>
);
export const FormFieldMap = FORM_FIELD_MAP;
export default DatabaseConnectionForm;

View File

@ -0,0 +1,207 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { SupersetTheme, t } from '@superset-ui/core';
import { Switch } from 'src/common/components';
import InfoTooltip from 'src/components/InfoTooltip';
import ValidatedInput from 'src/components/Form/LabeledErrorBoundInput';
import { FieldPropTypes } from '.';
import { toggleStyle, infoTooltip } from '../styles';
export const hostField = ({
required,
changeMethods,
getValidation,
validationErrors,
db,
}: FieldPropTypes) => (
<ValidatedInput
id="host"
name="host"
value={db?.parameters?.host}
required={required}
hasTooltip
tooltipText={t(
'This can be either an IP address (e.g. 127.0.0.1) or a domain name (e.g. mydatabase.com).',
)}
validationMethods={{ onBlur: getValidation }}
errorMessage={validationErrors?.host}
placeholder="e.g. 127.0.0.1"
className="form-group-w-50"
label="Host"
onChange={changeMethods.onParametersChange}
/>
);
export const portField = ({
required,
changeMethods,
getValidation,
validationErrors,
db,
}: FieldPropTypes) => (
<>
<ValidatedInput
id="port"
name="port"
type="number"
required={required}
value={db?.parameters?.port as number}
validationMethods={{ onBlur: getValidation }}
errorMessage={validationErrors?.port}
placeholder="e.g. 5432"
className="form-group-w-50"
label="Port"
onChange={changeMethods.onParametersChange}
/>
</>
);
export const databaseField = ({
required,
changeMethods,
getValidation,
validationErrors,
db,
}: FieldPropTypes) => (
<ValidatedInput
id="database"
name="database"
required={required}
value={db?.parameters?.database}
validationMethods={{ onBlur: getValidation }}
errorMessage={validationErrors?.database}
placeholder="e.g. world_population"
label="Database name"
onChange={changeMethods.onParametersChange}
helpText={t('Copy the name of the database you are trying to connect to.')}
/>
);
export const usernameField = ({
required,
changeMethods,
getValidation,
validationErrors,
db,
}: FieldPropTypes) => (
<ValidatedInput
id="username"
name="username"
required={required}
value={db?.parameters?.username}
validationMethods={{ onBlur: getValidation }}
errorMessage={validationErrors?.username}
placeholder="e.g. Analytics"
label="Username"
onChange={changeMethods.onParametersChange}
/>
);
export const passwordField = ({
required,
changeMethods,
getValidation,
validationErrors,
db,
isEditMode,
}: FieldPropTypes) => (
<ValidatedInput
id="password"
name="password"
required={required}
type={isEditMode && 'password'}
value={db?.parameters?.password}
validationMethods={{ onBlur: getValidation }}
errorMessage={validationErrors?.password}
placeholder="e.g. ********"
label="Password"
onChange={changeMethods.onParametersChange}
/>
);
export const displayField = ({
changeMethods,
getValidation,
validationErrors,
db,
}: FieldPropTypes) => (
<>
<ValidatedInput
id="database_name"
name="database_name"
required
value={db?.database_name}
validationMethods={{ onBlur: getValidation }}
errorMessage={validationErrors?.database_name}
placeholder=""
label={t('Display Name')}
onChange={changeMethods.onChange}
helpText={t(
'Pick a nickname for this database to display as in Superset.',
)}
/>
</>
);
export const queryField = ({
required,
changeMethods,
getValidation,
validationErrors,
db,
}: FieldPropTypes) => (
<ValidatedInput
id="query_input"
name="query_input"
required={required}
value={db?.query_input || ''}
validationMethods={{ onBlur: getValidation }}
errorMessage={validationErrors?.query}
placeholder="e.g. param1=value1&param2=value2"
label="Additional Parameters"
onChange={changeMethods.onQueryChange}
helpText={t('Add additional custom parameters')}
/>
);
export const forceSSLField = ({
isEditMode,
changeMethods,
db,
sslForced,
}: FieldPropTypes) => (
<div css={(theme: SupersetTheme) => infoTooltip(theme)}>
<Switch
disabled={sslForced && !isEditMode}
checked={db?.parameters?.encryption || sslForced}
onChange={changed => {
changeMethods.onParametersChange({
target: {
type: 'toggle',
name: 'encryption',
checked: true,
value: changed,
},
});
}}
/>
<span css={toggleStyle}>SSL</span>
<InfoTooltip
tooltip={t('SSL Mode "require" will be used.')}
placement="right"
viewBox="0 -5 24 24"
/>
</div>
);

View File

@ -0,0 +1,198 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { useState } from 'react';
import { SupersetTheme, t } from '@superset-ui/core';
import { Select, Button } from 'src/common/components';
import InfoTooltip from 'src/components/InfoTooltip';
import FormLabel from 'src/components/Form/FormLabel';
import { DeleteFilled } from '@ant-design/icons';
import { FieldPropTypes } from '.';
import { infoTooltip, labelMarginBotton, CredentialInfoForm } from '../styles';
enum CredentialInfoOptions {
jsonUpload,
copyPaste,
}
// These are the columns that are going to be added to encrypted extra, they differ in name based
// on the engine, however we want to use the same component for each of them. Make sure to add the
// the engine specific name here.
export const encryptedCredentialsMap = {
gsheets: 'service_account_info',
bigquery: 'credentials_info',
};
const castStringToBoolean = (optionValue: string) => optionValue === 'true';
export const EncryptedField = ({
changeMethods,
isEditMode,
db,
editNewDb,
}: FieldPropTypes) => {
const [uploadOption, setUploadOption] = useState<number>(
CredentialInfoOptions.jsonUpload.valueOf(),
);
const [fileToUpload, setFileToUpload] = useState<string | null | undefined>(
null,
);
const [isPublic, setIsPublic] = useState<boolean>(true);
const showCredentialsInfo =
db?.engine === 'gsheets' ? !isEditMode && !isPublic : !isEditMode;
// a database that has an optional encrypted field has an encrypted_extra that is an empty object, this checks for that.
const isEncrypted = isEditMode && db?.encrypted_extra !== '{}';
const encryptedField = db?.engine && encryptedCredentialsMap[db.engine];
const encryptedValue =
typeof db?.parameters?.[encryptedField] === 'object'
? JSON.stringify(db?.parameters?.[encryptedField])
: db?.parameters?.[encryptedField];
return (
<CredentialInfoForm>
{db?.engine === 'gsheets' && (
<div className="catalog-type-select">
<FormLabel
css={(theme: SupersetTheme) => labelMarginBotton(theme)}
required
>
{t('Type of Google Sheets allowed')}
</FormLabel>
<Select
style={{ width: '100%' }}
defaultValue={isEncrypted ? 'false' : 'true'}
onChange={(value: string) =>
setIsPublic(castStringToBoolean(value))
}
>
<Select.Option value="true" key={1}>
{t('Publicly shared sheets only')}
</Select.Option>
<Select.Option value="false" key={2}>
{t('Public and privately shared sheets')}
</Select.Option>
</Select>
</div>
)}
{showCredentialsInfo && (
<>
<FormLabel required>
{t('How do you want to enter service account credentials?')}
</FormLabel>
<Select
defaultValue={uploadOption}
style={{ width: '100%' }}
onChange={option => setUploadOption(option)}
>
<Select.Option value={CredentialInfoOptions.jsonUpload}>
{t('Upload JSON file')}
</Select.Option>
<Select.Option value={CredentialInfoOptions.copyPaste}>
{t('Copy and Paste JSON credentials')}
</Select.Option>
</Select>
</>
)}
{uploadOption === CredentialInfoOptions.copyPaste ||
isEditMode ||
editNewDb ? (
<div className="input-container">
<FormLabel required>{t('Service Account')}</FormLabel>
<textarea
className="input-form"
name={encryptedField}
value={encryptedValue}
onChange={changeMethods.onParametersChange}
placeholder="Paste content of service credentials JSON file here"
/>
<span className="label-paste">
{t('Copy and paste the entire service account .json file here')}
</span>
</div>
) : (
showCredentialsInfo && (
<div
className="input-container"
css={(theme: SupersetTheme) => infoTooltip(theme)}
>
<div css={{ display: 'flex', alignItems: 'center' }}>
<FormLabel required>{t('Upload Credentials')}</FormLabel>
<InfoTooltip
tooltip={t(
'Use the JSON file you automatically downloaded when creating your service account.',
)}
viewBox="0 0 24 24"
/>
</div>
{!fileToUpload && (
<Button
className="input-upload-btn"
onClick={() =>
document?.getElementById('selectedFile')?.click()
}
>
{t('Choose File')}
</Button>
)}
{fileToUpload && (
<div className="input-upload-current">
{fileToUpload}
<DeleteFilled
onClick={() => {
setFileToUpload(null);
changeMethods.onParametersChange({
target: {
name: encryptedField,
value: '',
},
});
}}
/>
</div>
)}
<input
id="selectedFile"
className="input-upload"
type="file"
onChange={async event => {
let file;
if (event.target.files) {
file = event.target.files[0];
}
setFileToUpload(file?.name);
changeMethods.onParametersChange({
target: {
type: null,
name: encryptedField,
value: await file?.text(),
checked: false,
},
});
(document.getElementById(
'selectedFile',
) as HTMLInputElement).value = null as any;
}}
/>
</div>
)
)}
</CredentialInfoForm>
);
};

View File

@ -0,0 +1,104 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { t } from '@superset-ui/core';
import ValidatedInput from 'src/components/Form/LabeledErrorBoundInput';
import FormLabel from 'src/components/Form/FormLabel';
import { CloseOutlined } from '@ant-design/icons';
import { FieldPropTypes } from '.';
import { StyledFooterButton, StyledCatalogTable } from '../styles';
import { CatalogObject } from '../../types';
export const TableCatalog = ({
required,
changeMethods,
getValidation,
validationErrors,
db,
}: FieldPropTypes) => {
const tableCatalog = db?.catalog || [];
const catalogError = validationErrors || {};
return (
<StyledCatalogTable>
<h4 className="gsheet-title">
{t('Connect Google Sheets as tables to this database')}
</h4>
<div>
{tableCatalog?.map((sheet: CatalogObject, idx: number) => (
<>
<FormLabel className="catalog-label" required>
{t('Google Sheet Name and URL')}
</FormLabel>
<div className="catalog-name">
<ValidatedInput
className="catalog-name-input"
required={required}
validationMethods={{ onBlur: getValidation }}
errorMessage={catalogError[idx]?.name}
placeholder={t('Enter a name for this sheet')}
onChange={(e: { target: { value: any } }) => {
changeMethods.onParametersChange({
target: {
type: `catalog-${idx}`,
name: 'name',
value: e.target.value,
},
});
}}
value={sheet.name}
/>
{tableCatalog?.length > 1 && (
<CloseOutlined
className="catalog-delete"
onClick={() => changeMethods.onRemoveTableCatalog(idx)}
/>
)}
</div>
<ValidatedInput
className="catalog-name-url"
required={required}
validationMethods={{ onBlur: getValidation }}
errorMessage={catalogError[idx]?.url}
placeholder={t('Paste the shareable Google Sheet URL here')}
onChange={(e: { target: { value: any } }) =>
changeMethods.onParametersChange({
target: {
type: `catalog-${idx}`,
name: 'value',
value: e.target.value,
},
})
}
value={sheet.value}
/>
</>
))}
<StyledFooterButton
className="catalog-add-btn"
onClick={() => {
changeMethods.onAddTableCatalog();
}}
>
+ {t('Add sheet')}
</StyledFooterButton>
</div>
</StyledCatalogTable>
);
};

View File

@ -0,0 +1,62 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { t } from '@superset-ui/core';
import ValidatedInput from 'src/components/Form/LabeledErrorBoundInput';
import { FieldPropTypes } from '.';
const FIELD_TEXT_MAP = {
account: {
helpText: t(
'Copy the account name of that database you are trying to connect to.',
),
placeholder: 'e.g. world_population',
},
warehouse: {
placeholder: 'e.g. compute_wh',
className: 'form-group-w-50',
},
role: {
placeholder: 'e.g. AccountAdmin',
className: 'form-group-w-50',
},
};
export const validatedInputField = ({
required,
changeMethods,
getValidation,
validationErrors,
db,
field,
}: FieldPropTypes) => (
<ValidatedInput
id={field}
name={field}
required={required}
value={db?.parameters?.[field]}
validationMethods={{ onBlur: getValidation }}
errorMessage={validationErrors?.[field]}
placeholder={FIELD_TEXT_MAP[field].placeholder}
helpText={FIELD_TEXT_MAP[field].helpText}
label={field}
onChange={changeMethods.onParametersChange}
className={FIELD_TEXT_MAP[field].className || field}
/>
);

View File

@ -0,0 +1,172 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { FormEvent } from 'react';
import { SupersetTheme, JsonObject } from '@superset-ui/core';
import { InputProps } from 'antd/lib/input';
import {
hostField,
portField,
databaseField,
usernameField,
passwordField,
displayField,
queryField,
forceSSLField,
} from './CommonParameters';
import { validatedInputField } from './ValidatedInputField';
import { EncryptedField } from './EncryptedField';
import { TableCatalog } from './TableCatalog';
import { formScrollableStyles, validatedFormStyles } from '../styles';
import { DatabaseForm, DatabaseObject } from '../../types';
export const FormFieldOrder = [
'host',
'port',
'database',
'username',
'password',
'database_name',
'credentials_info',
'service_account_info',
'catalog',
'query',
'encryption',
'account',
'warehouse',
'role',
];
export interface FieldPropTypes {
required: boolean;
hasTooltip?: boolean;
tooltipText?: (value: any) => string;
onParametersChange: (value: any) => string;
onParametersUploadFileChange: (value: any) => string;
changeMethods: { onParametersChange: (value: any) => string } & {
onChange: (value: any) => string;
} & {
onQueryChange: (value: any) => string;
} & { onParametersUploadFileChange: (value: any) => string } & {
onAddTableCatalog: () => void;
onRemoveTableCatalog: (idx: number) => void;
};
validationErrors: JsonObject | null;
getValidation: () => void;
db?: DatabaseObject;
field: string;
isEditMode?: boolean;
sslForced?: boolean;
defaultDBName?: string;
editNewDb?: boolean;
}
const FORM_FIELD_MAP = {
host: hostField,
port: portField,
database: databaseField,
username: usernameField,
password: passwordField,
database_name: displayField,
query: queryField,
encryption: forceSSLField,
credentials_info: EncryptedField,
service_account_info: EncryptedField,
catalog: TableCatalog,
warehouse: validatedInputField,
role: validatedInputField,
account: validatedInputField,
};
const DatabaseConnectionForm = ({
dbModel: { parameters },
onParametersChange,
onChange,
onQueryChange,
onParametersUploadFileChange,
onAddTableCatalog,
onRemoveTableCatalog,
validationErrors,
getValidation,
db,
isEditMode = false,
sslForced,
editNewDb,
}: {
isEditMode?: boolean;
sslForced: boolean;
editNewDb?: boolean;
dbModel: DatabaseForm;
db: Partial<DatabaseObject> | null;
onParametersChange: (
event: FormEvent<InputProps> | { target: HTMLInputElement },
) => void;
onChange: (
event: FormEvent<InputProps> | { target: HTMLInputElement },
) => void;
onQueryChange: (
event: FormEvent<InputProps> | { target: HTMLInputElement },
) => void;
onParametersUploadFileChange?: (
event: FormEvent<InputProps> | { target: HTMLInputElement },
) => void;
onAddTableCatalog: () => void;
onRemoveTableCatalog: (idx: number) => void;
validationErrors: JsonObject | null;
getValidation: () => void;
}) => (
<>
<div
// @ts-ignore
css={(theme: SupersetTheme) => [
formScrollableStyles,
validatedFormStyles(theme),
]}
>
{parameters &&
FormFieldOrder.filter(
(key: string) =>
Object.keys(parameters.properties).includes(key) ||
key === 'database_name',
).map(field =>
FORM_FIELD_MAP[field]({
required: parameters.required?.includes(field),
changeMethods: {
onParametersChange,
onChange,
onQueryChange,
onParametersUploadFileChange,
onAddTableCatalog,
onRemoveTableCatalog,
},
validationErrors,
getValidation,
db,
key: field,
field,
isEditMode,
sslForced,
editNewDb,
}),
)}
</div>
</>
);
export const FormFieldMap = FORM_FIELD_MAP;
export default DatabaseConnectionForm;

View File

@ -49,6 +49,9 @@ export type DatabaseObject = {
query?: Record<string, string>;
catalog?: Record<string, string>;
properties?: Record<string, any>;
warehouse?: string;
role?: string;
account?: string;
};
configuration_method: CONFIGURATION_METHOD;
engine?: string;

View File

@ -17,14 +17,18 @@
import json
import re
from datetime import datetime
from typing import Any, Dict, Optional, Pattern, Tuple, TYPE_CHECKING
from typing import Any, Dict, List, Optional, Pattern, Tuple, TYPE_CHECKING
from urllib import parse
from apispec import APISpec
from apispec.ext.marshmallow import MarshmallowPlugin
from flask_babel import gettext as __
from sqlalchemy.engine.url import URL
from marshmallow import fields, Schema
from sqlalchemy.engine.url import make_url, URL
from typing_extensions import TypedDict
from superset.db_engine_specs.postgres import PostgresBaseEngineSpec
from superset.errors import SupersetErrorType
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.models.sql_lab import Query
from superset.utils import core as utils
@ -42,12 +46,34 @@ SYNTAX_ERROR_REGEX = re.compile(
)
class SnowflakeParametersSchema(Schema):
username = fields.Str(required=True)
password = fields.Str(required=True)
account = fields.Str(required=True)
database = fields.Str(required=True)
role = fields.Str(required=True)
warehouse = fields.Str(required=True)
class SnowflakeParametersType(TypedDict):
username: str
password: str
account: str
database: str
role: str
warehouse: str
class SnowflakeEngineSpec(PostgresBaseEngineSpec):
engine = "snowflake"
engine_name = "Snowflake"
force_column_alias_quotes = True
max_column_name_length = 256
parameters_schema = SnowflakeParametersSchema()
default_driver = "snowflake"
sqlalchemy_uri_placeholder = "snowflake://"
_time_grain_expressions = {
None: "{col}",
"PT1S": "DATE_TRUNC('SECOND', {col})",
@ -160,3 +186,91 @@ class SnowflakeEngineSpec(PostgresBaseEngineSpec):
return False
return True
@classmethod
def build_sqlalchemy_uri(
cls,
parameters: SnowflakeParametersType,
encrypted_extra: Optional[ # pylint: disable=unused-argument
Dict[str, Any]
] = None,
) -> str:
return str(
URL(
"snowflake",
username=parameters.get("username"),
password=parameters.get("password"),
host=parameters.get("account"),
database=parameters.get("database"),
query={
"role": parameters.get("role"),
"warehouse": parameters.get("warehouse"),
},
)
)
@classmethod
def get_parameters_from_uri(
cls,
uri: str,
encrypted_extra: Optional[ # pylint: disable=unused-argument
Dict[str, str]
] = None,
) -> Any:
url = make_url(uri)
query = dict(url.query.items())
return {
"username": url.username,
"password": url.password,
"account": url.host,
"database": url.database,
"role": query.get("role"),
"warehouse": query.get("warehouse"),
}
@classmethod
def validate_parameters(
cls, parameters: SnowflakeParametersType # pylint: disable=unused-argument
) -> List[SupersetError]:
errors: List[SupersetError] = []
required = {
"warehouse",
"username",
"database",
"account",
"role",
"password",
}
present = {key for key in parameters if parameters.get(key, ())}
missing = sorted(required - present)
if missing:
errors.append(
SupersetError(
message=f'One or more parameters are missing: {", ".join(missing)}',
error_type=SupersetErrorType.CONNECTION_MISSING_PARAMETERS_ERROR,
level=ErrorLevel.WARNING,
extra={"missing": missing},
),
)
return errors
@classmethod
def parameters_json_schema(cls) -> Any:
"""
Return configuration parameters as OpenAPI.
"""
if not cls.parameters_schema:
return None
ma_plugin = MarshmallowPlugin()
spec = APISpec(
title="Database Parameters",
version="1.0.0",
openapi_version="3.0.0",
plugins=[ma_plugin],
)
spec.components.schema(cls.__name__, schema=cls.parameters_schema)
return spec.to_dict()["components"]["schemas"][cls.__name__]