feat: Database Connection UI (#14881)

This commit is contained in:
Hugh A. Miles II 2021-07-01 14:40:27 -07:00 committed by GitHub
parent 7f2f51b2bd
commit d4480f5c9a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 3282 additions and 996 deletions

View File

@ -312,6 +312,14 @@ The results stored in the backend were stored in a different format, and no long
The query results were stored in a format that is no longer supported. Please re-run your query.
## Issue 1034
```
The database port provided is invalid.
```
Please check that the provided database port is an integer between 0 and 65535 (inclusive).
## Issue 1035
```

View File

@ -21,71 +21,50 @@ import { DATABASE_LIST } from './helper';
describe('Add database', () => {
beforeEach(() => {
cy.login();
});
it('should keep create modal open when error', () => {
cy.visit(DATABASE_LIST);
// open modal
cy.wait(3000);
cy.get('[data-test="btn-create-database"]').click();
// values should be blank
cy.get('[data-test="database-modal"] input[name="database_name"]').should(
'have.value',
'',
);
cy.get('[data-test="database-modal"] input[name="sqlalchemy_uri"]').should(
'have.value',
'',
);
// type values
cy.get('[data-test="database-modal"] input[name="database_name"]')
.focus()
.type('cypress');
cy.get('[data-test="database-modal"] input[name="sqlalchemy_uri"]')
.focus()
.type('bad_db_uri');
// click save
cy.get('[data-test="modal-confirm-button"]:not(:disabled)').click();
// should show error alerts and keep modal open
cy.get('.toast').contains('error');
cy.wait(1000); // wait for potential (incorrect) closing annimation
cy.get('[data-test="database-modal"]').should('be.visible');
// should be able to close modal
cy.get('[data-test="modal-cancel-button"]').click();
cy.get('[data-test="database-modal"]').should('not.be.visible');
});
it('should keep update modal open when error', () => {
// open modal
cy.get('[data-test="database-edit"]:last').click();
it('should open dynamic form', () => {
// click postgres dynamic form
cy.get('.preferred > :nth-child(1)').click();
// it should show saved values
cy.get('[data-test="database-modal"]:last input[name="sqlalchemy_uri"]')
.invoke('val')
.should('not.be.empty');
cy.get('[data-test="database-modal"] input[name="database_name"]')
.invoke('val')
.should('not.be.empty');
// make sure all the fields are rendering
cy.get('input[name="host"]').should('have.value', '');
cy.get('input[name="port"]').should('have.value', '');
cy.get('input[name="database"]').should('have.value', '');
cy.get('input[name="password"]').should('have.value', '');
cy.get('input[name="database_name"]').should('have.value', '');
});
cy.get('[data-test="database-modal"]:last input[name="sqlalchemy_uri"]')
.focus()
.dblclick()
.type('{selectall}{backspace}bad_uri');
it('should open sqlalchemy form', () => {
// click postgres dynamic form
cy.get('.preferred > :nth-child(1)').click();
// click save
cy.get('[data-test="modal-confirm-button"]:not(:disabled)').click();
cy.get('[data-test="sqla-connect-btn"]').click();
// should show error alerts
// TODO(hugh): Update this test
// cy.get('.toast').contains('error').should('be.visible');
// check if the sqlalchemy form is showing up
cy.get('[data-test=database-name-input]').should('be.visible');
cy.get('[data-test="sqlalchemy-uri-input"]').should('be.visible');
});
// modal should still be open
// cy.wait(1000); // wait for potential (incorrect) closing annimation
// cy.get('[data-test="database-modal"]').should('be.visible');
it('show error alerts on dynamic form for bad host', () => {
// click postgres dynamic form
cy.get('.preferred > :nth-child(1)').click();
cy.get('input[name="host"]').focus().type('badhost');
cy.get('input[name="port"]').focus().type('5432');
cy.get('.ant-form-item-explain-error').contains(
"The hostname provided can't be resolved",
);
});
it('show error alerts on dynamic form for bad port', () => {
// click postgres dynamic form
cy.get('.preferred > :nth-child(1)').click();
cy.get('input[name="host"]').focus().type('localhost');
cy.get('input[name="port"]').focus().type('123');
cy.get('input[name="database"]').focus();
cy.get('.ant-form-item-explain-error').contains('The port is closed');
});
});

View File

@ -0,0 +1,21 @@
<!--
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.
-->
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path opacity="0.4" fill-rule="evenodd" clip-rule="evenodd" d="M15.9942 14.9661C16.0473 14.9256 16.0843 14.893 16.1094 14.8686V12.3207C15.7158 12.5563 15.2568 12.7608 14.7565 12.9359C13.2501 13.4632 11.2149 13.7763 8.99998 13.7763C6.78508 13.7763 4.74988 13.4632 3.24347 12.9359C2.74318 12.7608 2.28418 12.5563 1.89058 12.3207V14.8686C1.91568 14.893 1.95268 14.9256 2.00578 14.9661C2.26078 15.1603 2.71837 15.3956 3.40987 15.6184C4.77707 16.059 6.75368 16.3544 8.99998 16.3544C11.2463 16.3544 13.2229 16.059 14.5901 15.6184C15.2816 15.3956 15.7392 15.1603 15.9942 14.9661ZM15.7503 10.8614C16.0622 10.6033 16.1094 10.4232 16.1094 10.3388V8.41454C15.7158 8.65004 15.2568 8.85464 14.7565 9.02974C13.2501 9.55694 11.2149 9.87004 8.99998 9.87004C6.78508 9.87004 4.74988 9.55694 3.24347 9.02974C2.74318 8.85464 2.28418 8.65004 1.89058 8.41454V10.3388C1.89058 10.4232 1.93777 10.6033 2.24967 10.8614C2.55707 11.1158 3.04418 11.3763 3.70798 11.6086C5.02918 12.071 6.90007 12.37 8.99998 12.37C11.0999 12.37 12.9708 12.071 14.292 11.6086C14.9558 11.3763 15.4429 11.1158 15.7503 10.8614ZM0.484375 6.43254V10.3388V15.0165C0.484375 16.5321 4.29698 17.7607 8.99998 17.7607C13.703 17.7607 17.5156 16.5321 17.5156 15.0165V10.3388V6.43254V3.31734C17.5156 1.80174 13.703 0.573242 8.99998 0.573242H8.99508H8.99018C4.29258 0.573242 0.484375 1.80174 0.484375 3.31734V6.43254ZM16.1094 6.43254V4.81964C14.59 5.56754 11.9689 6.06144 8.99018 6.06144C6.02428 6.06144 3.41288 5.57174 1.89058 4.82924V6.43254C1.89058 6.51694 1.93777 6.69714 2.24967 6.95524C2.55707 7.20954 3.04418 7.47004 3.70798 7.70244C5.02918 8.16484 6.90007 8.46384 8.99998 8.46384C11.0999 8.46384 12.9708 8.16484 14.292 7.70244C14.9558 7.47004 15.4429 7.20954 15.7503 6.95524C16.0622 6.69714 16.1094 6.51694 16.1094 6.43254ZM2.07487 3.31764C2.33937 3.50124 2.77558 3.71554 3.40748 3.91944C4.77268 4.35984 6.74668 4.65524 8.99018 4.65524C11.2337 4.65524 13.2078 4.35984 14.573 3.91944C15.2053 3.71544 15.6416 3.50104 15.9061 3.31734C15.6416 3.13364 15.2053 2.91924 14.573 2.71524C13.2087 2.27514 11.2366 1.97984 8.99508 1.97944C6.75078 1.97984 4.77607 2.27514 3.40987 2.71544C2.77677 2.91944 2.33977 3.13384 2.07487 3.31764ZM16.1561 14.8151C16.1565 14.8152 16.1546 14.8186 16.1493 14.8253C16.1532 14.8185 16.1558 14.8151 16.1561 14.8151ZM1.84998 3.50934C1.84668 3.51524 1.84438 3.51814 1.84408 3.51804H1.84398C1.84458 3.51684 1.84648 3.51394 1.84998 3.50934ZM1.84387 14.8151C1.84417 14.8151 1.84678 14.8185 1.85068 14.8253C1.84538 14.8186 1.84347 14.8152 1.84387 14.8151Z" fill="#444E7C"/>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -99,6 +99,7 @@
"react-js-cron": "^1.2.0",
"react-json-tree": "^0.11.2",
"react-jsonschema-form": "^1.2.0",
"react-lines-ellipsis": "^0.15.0",
"react-loadable": "^5.5.0",
"react-markdown": "^4.3.1",
"react-redux": "^7.2.0",
@ -44276,6 +44277,15 @@
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
},
"node_modules/react-lines-ellipsis": {
"version": "0.15.0",
"resolved": "https://registry.npmjs.org/react-lines-ellipsis/-/react-lines-ellipsis-0.15.0.tgz",
"integrity": "sha512-8kWpEmu7ijmB6Gz5t+eSjNux2SpVXZBsmfeFE8LjMS7tU3H8ai475CyNc0dH0RDTwt4Esr7c06Xq4SB7Gpl9yQ==",
"peerDependencies": {
"react": "*",
"react-dom": "*"
}
},
"node_modules/react-loadable": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/react-loadable/-/react-loadable-5.5.0.tgz",
@ -89083,6 +89093,12 @@
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
},
"react-lines-ellipsis": {
"version": "0.15.0",
"resolved": "https://registry.npmjs.org/react-lines-ellipsis/-/react-lines-ellipsis-0.15.0.tgz",
"integrity": "sha512-8kWpEmu7ijmB6Gz5t+eSjNux2SpVXZBsmfeFE8LjMS7tU3H8ai475CyNc0dH0RDTwt4Esr7c06Xq4SB7Gpl9yQ==",
"requires": {}
},
"react-loadable": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/react-loadable/-/react-loadable-5.5.0.tgz",

View File

@ -151,6 +151,7 @@
"react-js-cron": "^1.2.0",
"react-json-tree": "^0.11.2",
"react-jsonschema-form": "^1.2.0",
"react-lines-ellipsis": "^0.15.0",
"react-loadable": "^5.5.0",
"react-markdown": "^4.3.1",
"react-redux": "^7.2.0",

View File

@ -33,6 +33,7 @@ export const ErrorTypeEnum = {
CONNECTION_INVALID_PASSWORD_ERROR: 'CONNECTION_INVALID_PASSWORD_ERROR',
CONNECTION_INVALID_HOSTNAME_ERROR: 'CONNECTION_INVALID_HOSTNAME_ERROR',
CONNECTION_PORT_CLOSED_ERROR: 'CONNECTION_PORT_CLOSED_ERROR',
CONNECTION_INVALID_PORT_ERROR: 'CONNECTION_INVALID_PORT_ERROR',
CONNECTION_HOST_DOWN_ERROR: 'CONNECTION_HOST_DOWN_ERROR',
CONNECTION_ACCESS_DENIED_ERROR: 'CONNECTION_ACCESS_DENIED_ERROR',
CONNECTION_UNKNOWN_DATABASE_ERROR: 'CONNECTION_UNKNOWN_DATABASE_ERROR',

View File

@ -32,6 +32,7 @@ export const InteractiveLabeledErrorBoundInput = ({
placeholder,
type,
id,
tooltipText,
}: LabeledErrorBoundInputProps) => {
const [currentValue, setCurrentValue] = useState(value);
@ -58,6 +59,8 @@ export const InteractiveLabeledErrorBoundInput = ({
placeholder={placeholder}
type={type}
required
hasTooltip
tooltipText={tooltipText}
/>
);
};
@ -66,6 +69,7 @@ InteractiveLabeledErrorBoundInput.args = {
name: 'Username',
placeholder: 'Example placeholder text...',
id: 1,
tooltipText: 'This is a tooltip',
};
InteractiveLabeledErrorBoundInput.argTypes = {

View File

@ -17,7 +17,7 @@
* under the License.
*/
import React from 'react';
import { render, screen } from 'spec/helpers/testing-library';
import { render, fireEvent, screen } from 'spec/helpers/testing-library';
import LabeledErrorBoundInput from 'src/components/Form/LabeledErrorBoundInput';
const defaultProps = {
@ -27,6 +27,8 @@ const defaultProps = {
validationMethods: () => {},
errorMessage: '',
helpText: 'This is a line of example help text',
hasTooltip: false,
tooltipText: 'This is a tooltip',
value: '',
placeholder: 'Example placeholder text...',
type: 'textbox',
@ -58,4 +60,19 @@ describe('LabeledErrorBoundInput', () => {
expect(textboxInput).toBeVisible();
expect(errorText).toBeVisible();
});
it('renders a LabledErrorBoundInput with a InfoTooltip', async () => {
defaultProps.hasTooltip = true;
render(<LabeledErrorBoundInput {...defaultProps} />);
const label = screen.getByText(/username/i);
const textboxInput = screen.getByRole('textbox');
const tooltipIcon = screen.getByRole('img');
fireEvent.mouseOver(tooltipIcon);
expect(tooltipIcon).toBeVisible();
expect(label).toBeVisible();
expect(textboxInput).toBeVisible();
expect(await screen.findByText('This is a tooltip')).toBeInTheDocument();
});
});

View File

@ -19,6 +19,8 @@
import React from 'react';
import { Input } from 'antd';
import { styled, css, SupersetTheme } from '@superset-ui/core';
import InfoTooltip from 'src/components/InfoTooltip';
import errorIcon from 'images/icons/error.svg';
import FormItem from './FormItem';
import FormLabel from './FormLabel';
@ -30,6 +32,8 @@ export interface LabeledErrorBoundInputProps {
errorMessage: string | null;
helpText?: string;
required?: boolean;
hasTooltip?: boolean;
tooltipText?: string | null;
id?: string;
classname?: string;
[x: string]: any;
@ -46,42 +50,63 @@ const alertIconStyles = (theme: SupersetTheme, hasError: boolean) => css`
${hasError &&
`.ant-form-item-control-input-content {
position: relative;
&:after {
content: ' ';
display: inline-block;
background: ${theme.colors.error.base};
mask: url('/images/icons/error.svg');
mask: url(${errorIcon});
mask-size: cover;
width: ${theme.gridUnit * 4}px;
height: ${theme.gridUnit * 4}px;
position: absolute;
right: 7px;
top: 15px;
right: ${theme.gridUnit * 1.25}px;
top: ${theme.gridUnit * 2.75}px;
}
}`}
`;
const StyledFormGroup = styled('div')`
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
margin-bottom: ${({ theme }) => theme.gridUnit * 5}px;
.ant-form-item {
margin-bottom: 0;
}
`;
const infoTooltip = (theme: SupersetTheme) => css`
svg {
vertical-align: bottom;
margin-bottom: ${theme.gridUnit * 0.25}px;
}
`;
const LabeledErrorBoundInput = ({
label,
validationMethods,
errorMessage,
helpText,
required = false,
hasTooltip = false,
tooltipText,
id,
className,
...props
}: LabeledErrorBoundInputProps) => (
<StyledFormGroup className={className}>
<FormLabel htmlFor={id} required={required}>
<FormLabel
htmlFor={id}
required={required}
css={(theme: SupersetTheme) => infoTooltip(theme)}
>
{label}
</FormLabel>
{hasTooltip && (
<InfoTooltip tooltip={`${tooltipText}`} viewBox="0 -6 24 24" />
)}
<FormItem
css={(theme: SupersetTheme) => alertIconStyles(theme, !!errorMessage)}
validateTrigger={Object.keys(validationMethods)}

View File

@ -145,6 +145,7 @@ import { ReactComponent as WarningIcon } from 'images/icons/warning.svg';
import { ReactComponent as WarningSolidIcon } from 'images/icons/warning_solid.svg';
import { ReactComponent as XLargeIcon } from 'images/icons/x-large.svg';
import { ReactComponent as XSmallIcon } from 'images/icons/x-small.svg';
import { ReactComponent as DefaultDatabaseIcon } from 'images/icons/default_db_image.svg';
export type IconName =
| 'alert'
@ -184,6 +185,7 @@ export type IconName =
| 'copy'
| 'cursor-target'
| 'database'
| 'default-database'
| 'dataset-physical'
| 'dataset-virtual'
| 'dataset-virtual-greyscale'
@ -299,6 +301,7 @@ export const iconsRegistry: Record<
'circle-check-solid': CircleCheckSolidIcon,
'color-palette': ColorPaletteIcon,
'cursor-target': CursorTargeIcon,
'default-database': DefaultDatabaseIcon,
'dataset-physical': DatasetPhysicalIcon,
'dataset-virtual': DatasetVirtualIcon,
'dataset-virtual-greyscale': DatasetVirtualGreyscaleIcon,

View File

@ -17,9 +17,11 @@
* under the License.
*/
import React from 'react';
import { styled } from '@superset-ui/core';
import { styled, supersetTheme } from '@superset-ui/core';
import Button from 'src/components/Button';
import { ButtonProps as AntdButtonProps } from 'antd/lib/button';
import Icon from 'src/components/Icon';
import LinesEllipsis from 'react-lines-ellipsis';
export interface IconButtonProps extends AntdButtonProps {
buttonText: string;
@ -33,17 +35,25 @@ const StyledButton = styled(Button)`
flex-direction: column;
padding: 0;
`;
const StyledImage = styled.div`
margin: ${({ theme }) => theme.gridUnit * 8}px 0;
padding: ${({ theme }) => theme.gridUnit * 4}px;
height: ${({ theme }) => theme.gridUnit * 18}px;
margin: ${({ theme }) => theme.gridUnit * 3}px 0;
&:first-of-type {
margin-right: 0;
}
img {
width: fit-content;
width: ${({ theme }) => theme.gridUnit * 10}px;
height: ${({ theme }) => theme.gridUnit * 10}px;
margin: 0;
&:first-of-type {
margin-right: 0;
}
}
svg {
&:first-of-type {
margin-right: 0;
}
@ -52,32 +62,21 @@ const StyledImage = styled.div`
const StyledInner = styled.div`
max-height: calc(1.5em * 2);
overflow: hidden;
padding-right: 1rem;
position: relative;
white-space: break-spaces;
&::before {
content: '...';
inset-block-end: 0; /* "bottom" */
inset-inline-end: 8px; /* "right" */
position: absolute;
&:first-of-type {
margin-right: 0;
}
&::after {
background-color: ${({ theme }) => theme.colors.grayscale.light4};
content: '';
height: 1rem;
inset-inline-end: 8px; /* "right" */
position: absolute;
top: 4px;
width: 1rem;
.LinesEllipsis {
&:first-of-type {
margin-right: 0;
}
}
`;
const StyledBottom = styled.div`
padding: ${({ theme }) => theme.gridUnit * 6}px
${({ theme }) => theme.gridUnit * 4}px;
padding: ${({ theme }) => theme.gridUnit * 4}px 0;
border-radius: 0 0 ${({ theme }) => theme.borderRadius}px
${({ theme }) => theme.borderRadius}px;
background-color: ${({ theme }) => theme.colors.grayscale.light4};
@ -96,10 +95,27 @@ const IconButton = styled(
({ icon, altText, buttonText, ...props }: IconButtonProps) => (
<StyledButton {...props}>
<StyledImage>
<img src={icon} alt={altText} />
{icon && <img src={icon} alt={altText} />}
{!icon && (
<Icon
color={supersetTheme.colors.primary.base}
height="40"
width="40"
viewBox="0 0 18 18"
name="default-database"
/>
)}
</StyledImage>
<StyledBottom>
<StyledInner>{buttonText}</StyledInner>
<StyledInner>
<LinesEllipsis
text={buttonText}
maxLine="2"
basedOn="words"
trimRight
/>
</StyledInner>
</StyledBottom>
</StyledButton>
),
@ -117,6 +133,7 @@ const IconButton = styled(
background-color: ${({ theme }) => theme.colors.grayscale.light5};
color: ${({ theme }) => theme.colors.grayscale.dark2};
border: 1px solid ${({ theme }) => theme.colors.grayscale.light2};
box-shadow: 4px 4px 20px ${({ theme }) => theme.colors.grayscale.light2};
}
`;

View File

@ -42,11 +42,11 @@ export interface InfoTooltipProps {
trigger?: string | Array<string>;
overlayStyle?: any;
bgColor?: string;
viewBox?: string;
}
const StyledTooltip = styled(Tooltip)`
cursor: pointer;
path:first-of-type {
fill: #999999;
}
@ -74,7 +74,7 @@ export default function InfoTooltip({
overlayStyle={overlayStyle}
color={bgColor}
>
<Icons.InfoSolidSmall />
<Icons.InfoSolidSmall className="info-solid-small" />
</StyledTooltip>
);
}

View File

@ -0,0 +1,47 @@
/**
* 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.
*/
declare module 'react-lines-ellipsis' {
interface ReactLinesEllipsisProps {
basedOn?: 'letters' | 'words';
className?: string;
component?: string;
ellipsis?: string;
isClamped?: () => boolean;
maxLine?: number | string;
onReflow?: ({ clamped, text }: { clamped: boolean; text: string }) => any;
style?: React.CSSProperties;
text?: string;
trimRight?: boolean;
winWidth?: number;
}
// eslint-disable-next-line react/prefer-stateless-function
class LinesEllipsis extends React.Component<ReactLinesEllipsisProps> {
static defaultProps?: ReactLinesEllipsisProps;
}
export default LinesEllipsis;
}
declare module 'react-lines-ellipsis/lib/responsiveHOC' {
export default function responsiveHOC(): <P extends object>(
WrappedComponent: React.ComponentType<P>,
) => React.ComponentClass<P>;
}

View File

@ -16,16 +16,27 @@
* specific language governing permissions and limitations
* under the License.
*/
import React, { FormEvent } from 'react';
import { SupersetTheme, JsonObject } from '@superset-ui/core';
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 } from '@ant-design/icons';
import {
StyledFormHeader,
formScrollableStyles,
validatedFormStyles,
CredentialInfoForm,
toggleStyle,
infoTooltip,
} from './styles';
import { DatabaseForm } from '../types';
import { DatabaseForm, DatabaseObject } from '../types';
enum CredentialInfoOptions {
jsonUpload,
copyPaste,
}
export const FormFieldOrder = [
'host',
@ -34,27 +45,171 @@ export const FormFieldOrder = [
'username',
'password',
'database_name',
'credentials_info',
'query',
'encryption',
];
interface FieldPropTypes {
required: boolean;
hasTooltip?: boolean;
tooltipText?: (valuse: any) => string;
onParametersChange: (value: any) => string;
onParametersUploadFileChange: (value: any) => string;
changeMethods: { onParametersChange: (value: any) => string } & {
onChange: (value: any) => string;
};
} & { onParametersUploadFileChange: (value: any) => string };
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,
);
return (
<CredentialInfoForm>
{!isEditMode && (
<>
<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="credentials_info"
value={db?.parameters?.credentials_info}
onChange={changeMethods.onParametersChange}
placeholder={JSON.stringify(
{
credentials_info: '<contents of credentials JSON file>',
},
null,
' ',
)}
/>
<span className="label-paste">
`${t('Copy and paste the entire service account .json file here')}`
</span>
</div>
) : (
<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 in Google BigQuery.',
)}
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: 'credentials_info',
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: 'credentials_info',
value: await file?.text(),
checked: false,
},
});
(document.getElementById(
'selectedFile',
) as HTMLInputElement).value = null as any;
}}
/>
</div>
)}
</CredentialInfoForm>
);
};
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"
@ -68,35 +223,42 @@ const portField = ({
changeMethods,
getValidation,
validationErrors,
db,
}: FieldPropTypes) => (
<ValidatedInput
id="port"
name="port"
required={required}
validationMethods={{ onBlur: getValidation }}
errorMessage={validationErrors?.port}
placeholder="e.g. 5432"
className="form-group-w-50"
label="Port"
onChange={changeMethods.onParametersChange}
/>
<>
<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="Copy the name of the PostgreSQL database you are trying to connect to."
helpText={t('Copy the name of the database you are trying to connect to.')}
/>
);
const usernameField = ({
@ -104,11 +266,13 @@ const usernameField = ({
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"
@ -121,11 +285,15 @@ const passwordField = ({
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. ********"
@ -134,24 +302,75 @@ const passwordField = ({
/>
);
const displayField = ({
required,
changeMethods,
getValidation,
validationErrors,
db,
}: FieldPropTypes) => (
<ValidatedInput
id="database_name"
name="database_name"
required={required}
required
value={db?.database_name}
validationMethods={{ onBlur: getValidation }}
errorMessage={validationErrors?.database_name}
placeholder=""
label="Display Name"
onChange={changeMethods.onChange}
helpText="Pick a nickname for this database to display as in Superset."
helpText={t('Pick a nickname for this database to display as in Superset.')}
/>
);
const queryField = ({
required,
changeMethods,
getValidation,
validationErrors,
db,
}: FieldPropTypes) => (
<ValidatedInput
id="query"
name="query"
required={required}
value={db?.parameters?.query}
validationMethods={{ onBlur: getValidation }}
errorMessage={validationErrors?.query}
placeholder="e.g. param1=value&param2=value2"
label="Additional Parameters"
onChange={changeMethods.onParametersChange}
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('SSL Mode "require" will be used.')}
placement="right"
/>
</div>
);
const FORM_FIELD_MAP = {
host: hostField,
port: portField,
@ -159,32 +378,41 @@ const FORM_FIELD_MAP = {
username: usernameField,
password: passwordField,
database_name: displayField,
query: queryField,
encryption: forceSSLField,
credentials_info: CredentialsInfo,
};
const DatabaseConnectionForm = ({
dbModel: { name, parameters },
dbModel: { parameters },
onParametersChange,
onChange,
onParametersUploadFileChange,
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;
onParametersUploadFileChange?: (
event: FormEvent<InputProps> | { target: HTMLInputElement },
) => void;
validationErrors: JsonObject | null;
getValidation: () => void;
}) => (
<>
<StyledFormHeader>
<h4>Enter the required {name} credentials</h4>
<p className="helper">
Need help? Learn more about connecting to {name}.
</p>
</StyledFormHeader>
<div
// @ts-ignore
css={(theme: SupersetTheme) => [
@ -199,11 +427,19 @@ const DatabaseConnectionForm = ({
key === 'database_name',
).map(field =>
FORM_FIELD_MAP[field]({
required: parameters.required.includes(field),
changeMethods: { onParametersChange, onChange },
required: parameters.required?.includes(field),
changeMethods: {
onParametersChange,
onChange,
onParametersUploadFileChange,
},
validationErrors,
getValidation,
db,
key: field,
isEditMode,
sslForced,
editNewDb,
}),
)}
</div>

View File

@ -18,7 +18,7 @@
*/
import React, { ChangeEvent, EventHandler } from 'react';
import cx from 'classnames';
import { t, SupersetTheme, styled } from '@superset-ui/core';
import { t, SupersetTheme } from '@superset-ui/core';
import InfoTooltip from 'src/components/InfoTooltip';
import IndeterminateCheckbox from 'src/components/IndeterminateCheckbox';
import Collapse from 'src/components/Collapse';
@ -27,374 +27,454 @@ import {
StyledJsonEditor,
StyledExpandableForm,
antdCollapseStyles,
no_margin_bottom,
} from './styles';
import { DatabaseObject } from '../types';
const defaultExtra =
'{\n "metadata_params": {},\n "engine_params": {},' +
'\n "metadata_cache_timeout": {},\n "schemas_allowed_for_csv_upload": [] \n}';
const StyledOptionContainer = styled.div`
.input-container > div > svg {
margin-top: ${({ theme }) => theme.gridUnit * 0.25}px;
}
span[role='img'] {
margin-top: ${({ theme }) => theme.gridUnit * 0.5}px;
}
`;
const ExtraOptions = ({
db,
onInputChange,
onTextChange,
onEditorChange,
onExtraInputChange,
onExtraEditorChange,
}: {
db: DatabaseObject | null;
onInputChange: EventHandler<ChangeEvent<HTMLInputElement>>;
onTextChange: EventHandler<ChangeEvent<HTMLTextAreaElement>>;
onEditorChange: Function;
onExtraInputChange: EventHandler<ChangeEvent<HTMLInputElement>>;
onExtraEditorChange: Function;
}) => {
const expandableModalIsOpen = !!db?.expose_in_sqllab;
const createAsOpen = !!(db?.allow_ctas || db?.allow_cvas);
return (
<StyledOptionContainer>
<Collapse
expandIconPosition="right"
accordion
css={(theme: SupersetTheme) => antdCollapseStyles(theme)}
<Collapse
expandIconPosition="right"
accordion
css={(theme: SupersetTheme) => antdCollapseStyles(theme)}
>
<Collapse.Panel
header={
<div>
<h4>SQL Lab</h4>
<p className="helper">
Adjust how this database will interact with SQL Lab.
</p>
</div>
}
key="1"
>
<Collapse.Panel
header={
<StyledInputContainer css={no_margin_bottom}>
<div className="input-container">
<IndeterminateCheckbox
id="expose_in_sqllab"
indeterminate={false}
checked={!!db?.expose_in_sqllab}
onChange={onInputChange}
labelText={t('Expose database in SQL Lab')}
/>
<InfoTooltip
tooltip={t('Allow this database to be queried in SQL Lab')}
/>
</div>
<StyledExpandableForm
className={cx('expandable', {
open: expandableModalIsOpen,
'ctas-open': createAsOpen,
})}
>
<StyledInputContainer css={no_margin_bottom}>
<div className="input-container">
<IndeterminateCheckbox
id="allow_ctas"
indeterminate={false}
checked={!!db?.allow_ctas}
onChange={onInputChange}
labelText={t('Allow CREATE TABLE AS')}
/>
<InfoTooltip
tooltip={t('Allow creation of new tables based on queries')}
/>
</div>
</StyledInputContainer>
<StyledInputContainer css={no_margin_bottom}>
<div className="input-container">
<IndeterminateCheckbox
id="allow_cvas"
indeterminate={false}
checked={!!db?.allow_cvas}
onChange={onInputChange}
labelText={t('Allow CREATE VIEW AS')}
/>
<InfoTooltip
tooltip={t('Allow creation of new views based on queries')}
/>
</div>
<StyledInputContainer
className={cx('expandable', { open: createAsOpen })}
>
<div className="control-label">{t('CTAS & CVAS SCHEMA')}</div>
<div className="input-container">
<input
type="text"
name="force_ctas_schema"
value={db?.force_ctas_schema || ''}
placeholder={t('Create or select schema...')}
onChange={onInputChange}
/>
</div>
<div className="helper">
{t(
'Force all tables and views to be created in this schema when clicking CTAS or CVAS in SQL Lab.',
)}
</div>
</StyledInputContainer>
</StyledInputContainer>
<StyledInputContainer css={no_margin_bottom}>
<div className="input-container">
<IndeterminateCheckbox
id="allow_dml"
indeterminate={false}
checked={!!db?.allow_dml}
onChange={onInputChange}
labelText={t('Allow DML')}
/>
<InfoTooltip
tooltip={t(
'Allow manipulation of the database using non-SELECT statements such as UPDATE, DELETE, CREATE, etc.',
)}
/>
</div>
</StyledInputContainer>
<StyledInputContainer css={no_margin_bottom}>
<div className="input-container">
<IndeterminateCheckbox
id="allow_multi_schema_metadata_fetch"
indeterminate={false}
checked={!!db?.allow_multi_schema_metadata_fetch}
onChange={onInputChange}
labelText={t('Allow Multi Schema Metadata Fetch')}
/>
<InfoTooltip
tooltip={t(
'Allow SQL Lab to fetch a list of all tables and all views across all database ' +
'schemas. For large data warehouse with thousands of tables, this can be ' +
'expensive and put strain on the system.',
)}
/>
</div>
</StyledInputContainer>
<StyledInputContainer css={no_margin_bottom}>
<div className="input-container">
<IndeterminateCheckbox
id="cost_query_enabled"
indeterminate={false}
checked={!!db?.extra_json?.cost_query_enabled}
onChange={onExtraInputChange}
labelText={t('Enable query cost estimation')}
/>
<InfoTooltip
tooltip={t(
'For Presto and Postgres, shows a button to compute cost before running a query.',
)}
/>
</div>
</StyledInputContainer>
<StyledInputContainer>
<div className="input-container">
<IndeterminateCheckbox
id="allows_virtual_table_explore"
indeterminate={false}
checked={!!db?.extra_json?.allows_virtual_table_explore}
onChange={onExtraInputChange}
labelText={t('Allow this database to be explored')}
/>
<InfoTooltip
tooltip={t(
'When enabled, users are able to visualize SQL Lab results in Explore.',
)}
/>
</div>
</StyledInputContainer>
</StyledExpandableForm>
</StyledInputContainer>
</Collapse.Panel>
<Collapse.Panel
header={
<div>
<h4>Performance</h4>
<p className="helper">
Adjust performance settings of this database.
</p>
</div>
}
key="2"
>
<StyledInputContainer className="mb-8">
<div className="control-label">{t('Chart cache timeout')}</div>
<div className="input-container">
<input
type="number"
name="cache_timeout"
value={db?.cache_timeout || ''}
placeholder={t('Enter duration in seconds')}
onChange={onInputChange}
/>
</div>
<div className="helper">
{t(
'Duration (in seconds) of the caching timeout for charts of this database.' +
' A timeout of 0 indicates that the cache never expires.' +
' Note this defaults to the global timeout if undefined.',
)}
</div>
</StyledInputContainer>
<StyledInputContainer>
<div className="control-label">{t('Schema cache timeout')}</div>
<div className="input-container">
<input
type="number"
name="schema_cache_timeout"
value={
db?.extra_json?.metadata_cache_timeout?.schema_cache_timeout ||
''
}
placeholder={t('Enter duration in seconds')}
onChange={onExtraInputChange}
data-test="schema-cache-timeout-test"
/>
</div>
<div className="helper">
{t(
'Duration (in seconds) of the metadata caching timeout for schemas of ' +
'this database. If left unset, the cache never expires.',
)}
</div>
</StyledInputContainer>
<StyledInputContainer>
<div className="control-label">{t('Table cache timeout')}</div>
<div className="input-container">
<input
type="number"
name="table_cache_timeout"
value={
db?.extra_json?.metadata_cache_timeout?.table_cache_timeout ||
''
}
placeholder={t('Enter duration in seconds')}
onChange={onExtraInputChange}
data-test="table-cache-timeout-test"
/>
</div>
<div className="helper">
{t(
'Duration (in seconds) of the metadata caching timeout for tables of ' +
'this database. If left unset, the cache never expires. ',
)}
</div>
</StyledInputContainer>
<StyledInputContainer css={{ no_margin_bottom }}>
<div className="input-container">
<IndeterminateCheckbox
id="allow_run_async"
indeterminate={false}
checked={!!db?.allow_run_async}
onChange={onInputChange}
labelText={t('Asynchronous query execution')}
/>
<InfoTooltip
tooltip={t(
'Operate the database in asynchronous mode, meaning that the queries ' +
'are executed on remote workers as opposed to on the web server itself. ' +
'This assumes that you have a Celery worker setup as well as a results ' +
'backend. Refer to the installation docs for more information.',
)}
/>
</div>
</StyledInputContainer>
</Collapse.Panel>
<Collapse.Panel
header={
<div>
<h4>Security</h4>
<p className="helper">Add extra connection information.</p>
</div>
}
key="3"
>
<StyledInputContainer>
<div className="control-label">{t('Secure extra')}</div>
<div className="input-container">
<StyledJsonEditor
name="encrypted_extra"
value={db?.encrypted_extra || ''}
placeholder={t('Secure extra')}
onChange={(json: string) =>
onEditorChange({ json, name: 'encrypted_extra' })
}
width="100%"
height="160px"
/>
</div>
<div className="helper">
<div>
<h4>SQL Lab</h4>
<p className="helper">
Configure how this database will function in SQL Lab.
</p>
</div>
}
key="1"
>
<StyledInputContainer className="mb-0">
<div className="input-container">
<IndeterminateCheckbox
id="expose_in_sqllab"
indeterminate={false}
checked={!!db?.expose_in_sqllab}
onChange={onInputChange}
labelText={t('Expose in SQL Lab')}
/>
<InfoTooltip
tooltip={t('Allow this database to be queried in SQL Lab')}
/>
</div>
<StyledExpandableForm
className={cx('expandable', {
open: expandableModalIsOpen,
'ctas-open': createAsOpen,
})}
>
<StyledInputContainer className="mb-0">
<div className="input-container">
<IndeterminateCheckbox
id="allow_ctas"
indeterminate={false}
checked={!!db?.allow_ctas}
onChange={onInputChange}
labelText={t('Allow CREATE TABLE AS')}
/>
<InfoTooltip
tooltip={t('Allow creation of new tables based on queries')}
/>
</div>
</StyledInputContainer>
<StyledInputContainer className="mb-0">
<div className="input-container">
<IndeterminateCheckbox
id="allow_cvas"
indeterminate={false}
checked={!!db?.allow_cvas}
onChange={onInputChange}
labelText={t('Allow CREATE VIEW AS')}
/>
<InfoTooltip
tooltip={t('Allow creation of new views based on queries')}
/>
</div>
<StyledInputContainer
className={cx('expandable', { open: createAsOpen })}
>
<div className="control-label">{t('CTAS & CVAS SCHEMA')}</div>
<div className="input-container">
<input
type="text"
name="force_ctas_schema"
value={db?.force_ctas_schema || ''}
placeholder={t('Search or select schema')}
onChange={onInputChange}
/>
</div>
<div className="helper">
{t(
'When allowing CREATE TABLE AS option in SQL Lab, this option ' +
'forces the table to be created in this schema.',
)}
</div>
</StyledInputContainer>
</StyledInputContainer>
<StyledInputContainer className="mb-0">
<div className="input-container">
<IndeterminateCheckbox
id="allow_dml"
indeterminate={false}
checked={!!db?.allow_dml}
onChange={onInputChange}
labelText={t('Allow DML')}
/>
<InfoTooltip
tooltip={t(
'Allow manipulation of the database using non-SELECT statements such as UPDATE, DELETE, CREATE, etc.',
)}
/>
</div>
</StyledInputContainer>
<StyledInputContainer>
<div className="input-container">
<IndeterminateCheckbox
id="allow_multi_schema_metadata_fetch"
indeterminate={false}
checked={!!db?.allow_multi_schema_metadata_fetch}
onChange={onInputChange}
labelText={t('Allow multi schema metadata fetch')}
/>
<InfoTooltip
tooltip={t(
'Allow SQL Lab to fetch a list of all tables and all views across all database ' +
'schemas. For large data warehouse with thousands of tables, this can be ' +
'expensive and put strain on the system.',
)}
/>
</div>
</StyledInputContainer>
</StyledExpandableForm>
</StyledInputContainer>
</Collapse.Panel>
<Collapse.Panel
header={
<div>
<h4>Performance</h4>
<p className="helper">
Adjust settings that will impact the performance of this
database.
</p>
</div>
}
key="2"
>
<StyledInputContainer className="mb-8">
<div className="control-label">{t('Chart cache timeout')}</div>
<div className="input-container">
<input
type="number"
name="cache_timeout"
value={db?.cache_timeout || ''}
placeholder={t('Chart cache timeout')}
onChange={onInputChange}
/>
</div>
<div className="helper">
{t(
'Duration (in seconds) of the caching timeout for charts of this database.' +
' A timeout of 0 indicates that the cache never expires.' +
' Note this defaults to the global timeout if undefined.',
'JSON string containing additional connection configuration. ' +
'This is used to provide connection information for systems ' +
'like Hive, Presto and BigQuery which do not conform to the ' +
'username:password syntax normally used by SQLAlchemy.',
)}
</div>
</StyledInputContainer>
<StyledInputContainer className="mb-0">
<div className="input-container">
<IndeterminateCheckbox
id="allow_run_async"
indeterminate={false}
checked={!!db?.allow_run_async}
onChange={onInputChange}
labelText={t('Asynchronous query execution')}
/>
<InfoTooltip
tooltip={t(
'Operate the database in asynchronous mode, meaning that the queries ' +
'are executed on remote workers as opposed to on the web server itself. ' +
'This assumes that you have a Celery worker setup as well as a results ' +
'backend. Refer to the installation docs for more information.',
)}
/>
</div>
</StyledInputContainer>
</Collapse.Panel>
<Collapse.Panel
header={
</div>
</StyledInputContainer>
<StyledInputContainer>
<div className="control-label">{t('Root certificate')}</div>
<div className="input-container">
<textarea
name="server_cert"
value={db?.server_cert || ''}
placeholder={t('Enter CA_BUNDLE')}
onChange={onTextChange}
/>
</div>
<div className="helper">
{t(
'Optional CA_BUNDLE contents to validate HTTPS requests. Only ' +
'available on certain database engines.',
)}
</div>
</StyledInputContainer>
<StyledInputContainer>
<div className="control-label">
{t('Schemas allowed for CSV upload')}
</div>
<div className="input-container">
<input
type="text"
name="schemas_allowed_for_csv_upload"
value={db?.extra_json?.schemas_allowed_for_csv_upload || ''}
placeholder={t('Select one or multiple schemas')}
onChange={onExtraInputChange}
/>
</div>
<div className="helper">
{t('A list of schemas that CSVs are allowed to upload to.')}
</div>
</StyledInputContainer>
<StyledInputContainer css={{ no_margin_bottom }}>
<div className="input-container">
<IndeterminateCheckbox
id="impersonate_user"
indeterminate={false}
checked={!!db?.impersonate_user}
onChange={onInputChange}
labelText={t('Impersonate logged in user (Presto & Hive)')}
/>
<InfoTooltip
tooltip={t(
'If Presto, all the queries in SQL Lab are going to be executed as the ' +
'currently logged on user who must have permission to run them. If Hive ' +
'and hive.server2.enable.doAs is enabled, will run the queries as ' +
'service account, but impersonate the currently logged on user via ' +
'hive.server2.proxy.user property.',
)}
/>
</div>
</StyledInputContainer>
<StyledInputContainer css={{ ...no_margin_bottom }}>
<div className="input-container">
<IndeterminateCheckbox
id="allow_csv_upload"
indeterminate={false}
checked={!!db?.allow_csv_upload}
onChange={onInputChange}
labelText={t('Allow data upload')}
/>
<InfoTooltip
tooltip={t(
'If selected, please set the schemas allowed for data upload in Extra.',
)}
/>
</div>
</StyledInputContainer>
</Collapse.Panel>
<Collapse.Panel
header={
<div>
<h4>Other</h4>
<p className="helper">Additional settings.</p>
</div>
}
key="4"
>
<StyledInputContainer>
<div className="control-label">{t('Metadata Parameters')}</div>
<div className="input-container">
<StyledJsonEditor
name="metadata_params"
value={db?.extra_json?.metadata_params || '{}'}
placeholder={t('Metadata Parameters')}
onChange={(json: string) =>
onExtraEditorChange({ json, name: 'metadata_params' })
}
width="100%"
height="160px"
/>
</div>
<div className="helper">
<div>
<h4>Security</h4>
<p className="helper">
Add connection information for other systems.
</p>
</div>
}
key="3"
>
<StyledInputContainer>
<div className="control-label">{t('Secure extra')}</div>
<div className="input-container">
<StyledJsonEditor
name="encrypted_extra"
value={db?.encrypted_extra || ''}
placeholder={t('Secure extra')}
onChange={(json: string) =>
onEditorChange({ json, name: 'encrypted_extra' })
}
width="100%"
height="160px"
/>
</div>
<div className="helper">
<div>
{t(
'JSON string containing additional connection configuration.',
)}
</div>
<div>
{t(
'This is used to provide connection information for systems like Hive, ' +
'Presto, and BigQuery, which do not conform to the username:password syntax ' +
'normally used by SQLAlchemy.',
)}
</div>
</div>
</StyledInputContainer>
<StyledInputContainer>
<div className="control-label">{t('Root certificate')}</div>
<div className="input-container">
<textarea
name="server_cert"
value={db?.server_cert || ''}
placeholder={t('Root certificate')}
onChange={onTextChange}
/>
</div>
<div className="helper">
{t(
'Optional CA_BUNDLE contents to validate HTTPS requests. Only available on ' +
'certain database engines.',
'The metadata_params object gets unpacked into the sqlalchemy.MetaData call.',
)}
</div>
</StyledInputContainer>
</Collapse.Panel>
<Collapse.Panel
header={
</div>
</StyledInputContainer>
<StyledInputContainer>
<div className="control-label">{t('Engine Parameters')}</div>
<div className="input-container">
<StyledJsonEditor
name="engine_params"
value={db?.extra_json?.engine_params || '{}'}
placeholder={t('Engine Parameters')}
onChange={(json: string) =>
onExtraEditorChange({ json, name: 'engine_params' })
}
width="100%"
height="160px"
/>
</div>
<div className="helper">
<div>
<h4>Other</h4>
<p className="helper">Additional settings.</p>
{t(
'The engine_params object gets unpacked into the sqlalchemy.create_engine call.',
)}
</div>
}
key="4"
>
<StyledInputContainer className="mb-0">
<div className="input-container">
<IndeterminateCheckbox
id="impersonate_user"
indeterminate={false}
checked={!!db?.impersonate_user}
onChange={onInputChange}
labelText={t(
'Impersonate Logged In User (Presto, Trino, Hive, and GSheets)',
)}
/>
<InfoTooltip
tooltip={t(
'If Presto or Trino, all the queries in SQL Lab are going to be executed as the ' +
'currently logged on user who must have permission to run them. If Hive ' +
'and hive.server2.enable.doAs is enabled, will run the queries as ' +
'service account, but impersonate the currently logged on user via ' +
'hive.server2.proxy.user property.',
)}
/>
</div>
</StyledInputContainer>
<StyledInputContainer className="mb-0">
<div className="input-container">
<IndeterminateCheckbox
id="allow_csv_upload"
indeterminate={false}
checked={!!db?.allow_csv_upload}
onChange={onInputChange}
labelText={t('Allow data upload')}
/>
<InfoTooltip
tooltip={t(
'If selected, please set the schemas allowed for data upload in Extra.',
)}
/>
</div>
</StyledInputContainer>
<StyledInputContainer className="extra-container">
<div className="control-label">{t('Extra')}</div>
<div className="input-container">
<StyledJsonEditor
name="extra"
value={db?.extra ?? defaultExtra}
placeholder={t('Secure extra')}
onChange={(json: string) =>
onEditorChange({ json, name: 'extra' })
}
width="100%"
height="160px"
/>
</div>
<div className="helper">
<div>
{t('JSON string containing extra configuration elements.')}
</div>
<div>
{t(
'1. The engine_params object gets unpacked into the sqlalchemy.create_engine ' +
'call, while the metadata_params gets unpacked into the sqlalchemy.MetaData ' +
'call.',
)}
</div>
<div>
{t(
'2. The metadata_cache_timeout is a cache timeout setting in seconds for ' +
'metadata fetch of this database. Specify it as "metadata_cache_timeout": ' +
'{"schema_cache_timeout": 600, "table_cache_timeout": 600}. If unset, cache ' +
'will not be enabled for the functionality. A timeout of 0 indicates that ' +
'the cache never expires.',
)}
</div>
<div>
{t(
'3. The schemas_allowed_for_csv_upload is a comma separated list of schemas ' +
'that CSVs are allowed to upload to. Specify it as ' +
'"schemas_allowed_for_csv_upload": ["public", "csv_upload"]. If database ' +
'flavor does not support schema or any schema is allowed to be accessed, ' +
'just leave the list empty.',
)}
</div>
<div>
{t(
"4. The version field is a string specifying this db's version. This " +
'should be used with Presto DBs so that the syntax is correct.',
)}
</div>
<div>
{t(
'5. The allows_virtual_table_explore field is a boolean specifying whether ' +
'or not the Explore button in SQL Lab results is shown.',
)}
</div>
</div>
</StyledInputContainer>
</Collapse.Panel>
</Collapse>
</StyledOptionContainer>
</div>
</StyledInputContainer>
<StyledInputContainer>
<div className="control-label" data-test="version-label-test">
{t('Version')}
</div>
<div className="input-container" data-test="version-spinbutton-test">
<input
type="number"
name="version"
value={db?.extra_json?.version || ''}
placeholder={t('Version number')}
onChange={onExtraInputChange}
/>
</div>
<div className="helper">
{t(
'Specify this databases version. This should be used with ' +
'Presto databases so that the syntax is correct.',
)}
</div>
</StyledInputContainer>
</Collapse.Panel>
</Collapse>
);
};

View File

@ -0,0 +1,161 @@
/**
* 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 { getDatabaseDocumentationLinks } from 'src/views/CRUD/hooks';
import {
EditHeaderTitle,
EditHeaderSubtitle,
CreateHeaderTitle,
CreateHeaderSubtitle,
StyledFormHeader,
StyledStickyHeader,
} from './styles';
import { DatabaseForm, DatabaseObject } from '../types';
export const DOCUMENTATION_LINK =
'https://superset.apache.org/docs/databases/installing-database-drivers';
const supersetTextDocs = getDatabaseDocumentationLinks();
const irregularDocumentationLinks = {
postgresql: 'https://superset.apache.org/docs/databases/postgres',
mssql: 'https://superset.apache.org/docs/databases/sql-server',
};
const documentationLink = (engine: string | undefined) => {
if (!engine) return null;
if (supersetTextDocs) {
// override doc link for superset_txt yml
return supersetTextDocs[engine] || supersetTextDocs.default;
}
if (!irregularDocumentationLinks[engine]) {
return `https://superset.apache.org/docs/databases/${engine}`;
}
return irregularDocumentationLinks[engine];
};
const ModalHeader = ({
isLoading,
isEditMode,
useSqlAlchemyForm,
hasConnectedDb,
db,
dbName,
dbModel,
editNewDb,
}: {
isLoading: boolean;
isEditMode: boolean;
useSqlAlchemyForm: boolean;
hasConnectedDb: boolean;
db: Partial<DatabaseObject> | null;
dbName: string;
dbModel: DatabaseForm;
editNewDb?: boolean;
}) => {
const isEditHeader = (
<StyledFormHeader>
<EditHeaderTitle>{db?.backend}</EditHeaderTitle>
<EditHeaderSubtitle>{dbName}</EditHeaderSubtitle>
</StyledFormHeader>
);
const useSqlAlchemyFormHeader = (
<StyledFormHeader>
<p className="helper"> STEP 2 OF 2 </p>
<CreateHeaderTitle>Enter Primary Credentials</CreateHeaderTitle>
<CreateHeaderSubtitle>
Need help? Learn how to connect your database{' '}
<a
href={supersetTextDocs?.default || DOCUMENTATION_LINK}
target="_blank"
rel="noopener noreferrer"
>
here
</a>
.
</CreateHeaderSubtitle>
</StyledFormHeader>
);
const hasConnectedDbHeader = (
<StyledStickyHeader>
<StyledFormHeader>
<p className="helper"> STEP 3 OF 3 </p>
<h4>
Your database was successfully connected! Here are some optional
settings for your database
</h4>
<p className="helper">
Need help? Learn more about{' '}
<a
href={documentationLink(db?.engine)}
target="_blank"
rel="noopener noreferrer"
>
connecting to {dbModel.name}.
</a>
</p>
</StyledFormHeader>
</StyledStickyHeader>
);
const hasDbHeader = (
<StyledStickyHeader>
<StyledFormHeader>
<p className="helper"> STEP 2 OF 3 </p>
<h4>Enter the required {dbModel.name} credentials</h4>
<p className="helper">
Need help? Learn more about{' '}
<a
href={documentationLink(db?.engine)}
target="_blank"
rel="noopener noreferrer"
>
connecting to {dbModel.name}.
</a>
</p>
</StyledFormHeader>
</StyledStickyHeader>
);
const noDbHeader = (
<StyledFormHeader>
<div className="select-db">
<p className="helper"> STEP 1 OF 3 </p>
<h4>Select a database to connect</h4>
</div>
</StyledFormHeader>
);
if (isLoading) return <></>;
if (isEditMode) {
return isEditHeader;
}
if (useSqlAlchemyForm) {
return useSqlAlchemyFormHeader;
}
if (hasConnectedDb && !editNewDb) {
return hasConnectedDbHeader;
}
if (db || editNewDb) {
return hasDbHeader;
}
return noDbHeader;
};
export default ModalHeader;

View File

@ -28,11 +28,13 @@ const SqlAlchemyTab = ({
onInputChange,
testConnection,
conf,
isEditMode = false,
}: {
db: DatabaseObject | null;
onInputChange: EventHandler<ChangeEvent<HTMLInputElement>>;
testConnection: EventHandler<MouseEvent<HTMLElement>>;
conf: { SQLALCHEMY_DOCS_URL: string; SQLALCHEMY_DISPLAY_TEXT: string };
isEditMode?: boolean;
}) => (
<>
<StyledInputContainer>

View File

@ -20,27 +20,49 @@
import { styled, css, SupersetTheme } from '@superset-ui/core';
import { JsonEditor } from 'src/components/AsyncAceEditor';
import Tabs from 'src/components/Tabs';
import Button from 'src/components/Button';
const CTAS_CVAS_SCHEMA_FORM_HEIGHT = 102;
const EXPOSE_IN_SQLLAB_FORM_HEIGHT = CTAS_CVAS_SCHEMA_FORM_HEIGHT + 52;
const EXPOSE_IN_SQLLAB_FORM_HEIGHT = CTAS_CVAS_SCHEMA_FORM_HEIGHT + 153;
const EXPOSE_ALL_FORM_HEIGHT = EXPOSE_IN_SQLLAB_FORM_HEIGHT + 102;
const anticonHeight = 12;
export const no_margin_bottom = css`
margin-bottom: 0;
`;
export const marginBottom = (theme: SupersetTheme) => css`
margin-bottom: ${theme.gridUnit * 4}px;
`;
export const StyledFormHeader = styled.header`
border-bottom: ${({ theme }) => `${theme.gridUnit * 0.25}px solid
${theme.colors.grayscale.light2};`}
padding-left: ${({ theme }) => theme.gridUnit * 4}px;
padding-right: ${({ theme }) => theme.gridUnit * 4}px;
margin-bottom: ${({ theme }) => theme.gridUnit * 4}px;
padding: ${({ theme }) => theme.gridUnit * 2}px
${({ theme }) => theme.gridUnit * 4}px;
line-height: ${({ theme }) => theme.gridUnit * 6}px;
.helper {
color: ${({ theme }) => theme.colors.grayscale.base};
font-size: ${({ theme }) => theme.typography.sizes.s - 1}px;
margin: 0;
}
h4 {
color: ${({ theme }) => theme.colors.grayscale.dark2};
font-weight: bold;
font-size: ${({ theme }) => theme.typography.sizes.l}px;
margin: 0;
padding: 0;
}
.select-db {
padding: ${({ theme }) => theme.gridUnit}px;
.helper {
margin: 0;
}
h4 {
margin: 0 0 ${({ theme }) => theme.gridUnit * 6}px;
}
}
`;
@ -69,6 +91,9 @@ export const antdCollapseStyles = (theme: SupersetTheme) => css`
`;
export const antDTabsStyles = css`
.ant-tabs-top {
margin-top: 0;
}
.ant-tabs-top > .ant-tabs-nav {
margin-bottom: 0;
}
@ -81,16 +106,31 @@ export const antDModalNoPaddingStyles = css`
.ant-modal-body {
padding-left: 0;
padding-right: 0;
padding-top: 0;
}
`;
export const infoTooltip = (theme: SupersetTheme) => css`
margin-bottom: ${theme.gridUnit * 5}px;
svg {
vertical-align: bottom;
margin-bottom: ${theme.gridUnit * 0.25}px;
}
`;
export const toggleStyle = (theme: SupersetTheme) => css`
padding-left: ${theme.gridUnit * 2}px;
`;
export const formScrollableStyles = (theme: SupersetTheme) => css`
overflow-y: scroll;
padding-left: ${theme.gridUnit * 4}px;
padding-right: ${theme.gridUnit * 4}px;
padding: ${theme.gridUnit * 4}px ${theme.gridUnit * 4}px 0;
`;
export const antDModalStyles = (theme: SupersetTheme) => css`
.ant-select-dropdown {
height: ${theme.gridUnit * 40}px;
}
.ant-modal-header {
padding: ${theme.gridUnit * 4.5}px ${theme.gridUnit * 4}px
${theme.gridUnit * 4}px;
@ -100,24 +140,65 @@ export const antDModalStyles = (theme: SupersetTheme) => css`
color: ${theme.colors.grayscale.dark1};
opacity: 1;
}
.ant-modal-title > h4 {
font-weight: bold;
}
.ant-modal-body {
height: ${theme.gridUnit * 180.5}px;
}
.ant-modal-footer {
height: ${theme.gridUnit * 16.25}px;
}
`;
export const antDAlertStyles = (theme: SupersetTheme) => css`
border: 1px solid ${theme.colors.info.base};
padding: ${theme.gridUnit * 4}px;
margin: ${theme.gridUnit * 8}px 0 0;
margin: ${theme.gridUnit * 4}px 0;
.ant-alert-message {
color: ${theme.colors.info.dark2};
font-size: ${theme.typography.sizes.s + 1}px;
font-weight: bold;
}
.ant-alert-description {
color: ${theme.colors.info.dark2};
font-size: ${theme.typography.sizes.s + 1}px;
line-height: ${theme.gridUnit * 4}px;
a {
text-decoration: underline;
}
.ant-alert-icon {
margin-right: ${theme.gridUnit * 2.5}px;
font-size: ${theme.typography.sizes.l + 1}px;
position: relative;
top: ${theme.gridUnit / 4}px;
}
}
`;
export const StyledAlertMargin = styled.div`
margin: 0 ${({ theme }) => theme.gridUnit * 4}px -${({ theme }) => theme.gridUnit * 4}px;
`;
export const antDErrorAlertStyles = (theme: SupersetTheme) => css`
border: ${theme.colors.error.base} 1px solid;
padding: ${theme.gridUnit * 4}px;
margin: ${theme.gridUnit * 8}px ${theme.gridUnit * 4}px;
color: ${theme.colors.error.dark2};
.ant-alert-message {
font-size: ${theme.typography.sizes.s + 1}px;
font-weight: bold;
}
.ant-alert-description {
font-size: ${theme.typography.sizes.s + 1}px;
line-height: ${theme.gridUnit * 4}px;
.ant-alert-icon {
margin-right: ${theme.gridUnit * 2.5}px;
font-size: ${theme.typography.sizes.l + 1}px;
@ -161,6 +242,7 @@ export const formStyles = (theme: SupersetTheme) => css`
width: ${`calc(50% - ${theme.gridUnit * 4}px)`};
& + .form-group-w-50 {
margin-left: ${theme.gridUnit * 8}px;
margin-bottom: ${theme.gridUnit * 10}px;
}
}
}
@ -296,14 +378,21 @@ export const StyledExpandableForm = styled.div`
`;
export const StyledBasicTab = styled(Tabs.TabPane)`
padding-left: ${({ theme }) => theme.gridUnit * 4}px;
padding-right: ${({ theme }) => theme.gridUnit * 4}px;
padding: 0 ${({ theme }) => theme.gridUnit * 4}px;
margin-top: ${({ theme }) => theme.gridUnit * 6}px;
`;
export const buttonLinkStyles = css`
export const buttonLinkStyles = (theme: SupersetTheme) => css`
font-weight: 400;
text-transform: initial;
padding-right: ${theme.gridUnit * 2}px;
`;
export const alchemyButtonLinkStyles = (theme: SupersetTheme) => css`
font-weight: 400;
text-transform: initial;
padding: ${theme.gridUnit * 8}px 0 0;
margin-left: 0px;
`;
export const TabHeader = styled.div`
@ -311,15 +400,18 @@ export const TabHeader = styled.div`
flex-direction: column;
justify-content: center;
padding: 0px;
margin: 0 ${({ theme }) => theme.gridUnit * 4}px
${({ theme }) => theme.gridUnit * 8}px;
.helper {
color: ${({ theme }) => theme.colors.grayscale.base};
font-size: ${({ theme }) => theme.typography.sizes.s - 1}px;
margin: 0px;
}
`;
export const CreateHeaderTitle = styled.div`
color: ${({ theme }) => theme.colors.grayscale.dark2};
font-weight: bold;
font-size: ${({ theme }) => theme.typography.sizes.m}px;
padding: ${({ theme }) => theme.gridUnit * 1}px 0;
`;
export const CreateHeaderSubtitle = styled.div`
@ -338,3 +430,100 @@ export const EditHeaderSubtitle = styled.div`
font-size: ${({ theme }) => theme.typography.sizes.l}px;
font-weight: bold;
`;
export const CredentialInfoForm = styled.div`
.label-select {
text-transform: uppercase;
color: ${({ theme }) => theme.colors.grayscale.dark1};
font-size: 11px;
margin: 0 5px ${({ theme }) => theme.gridUnit * 2}px;
}
.label-paste {
color: ${({ theme }) => theme.colors.grayscale.light1};
font-size: 11px;
line-height: 16px;
}
.input-container {
margin: ${({ theme }) => theme.gridUnit * 7}px 0;
display: flex;
flex-direction: column;
}
}
.input-form {
height: 100px;
width: 100%;
border: 1px solid ${({ theme }) => theme.colors.grayscale.light2};
border-radius: ${({ theme }) => theme.gridUnit}px;
resize: vertical;
padding: ${({ theme }) => theme.gridUnit * 1.5}px
${({ theme }) => theme.gridUnit * 2}px;
&::placeholder {
color: ${({ theme }) => theme.colors.grayscale.light1};
}
}
.input-container {
.input-upload {
display: none;
}
.input-upload-current {
display: flex;
justify-content: space-between;
}
.input-upload-btn {
width: ${({ theme }) => theme.gridUnit * 32}px
}
}`;
export const SelectDatabaseStyles = styled.div`
.preferred {
.superset-button {
margin-left: 0;
}
display: flex;
flex-wrap: wrap;
justify-content: space-between;
margin: ${({ theme }) => theme.gridUnit * 4}px;
}
.preferred-item {
width: 32%;
margin-bottom: ${({ theme }) => theme.gridUnit * 2.5}px;
}
.available {
margin: ${({ theme }) => theme.gridUnit * 4}px;
.available-label {
font-size: ${({ theme }) => theme.typography.sizes.l * 1.1}px;
font-weight: bold;
margin: ${({ theme }) => theme.gridUnit * 6}px 0;
}
.available-select {
width: 100%;
}
}
.label-available-select {
text-transform: uppercase;
font-size: ${({ theme }) => theme.typography.sizes.s - 1}px;
}
.control-label {
color: ${({ theme }) => theme.colors.grayscale.dark1};
font-size: ${({ theme }) => theme.typography.sizes.s - 1}px;
margin-bottom: ${({ theme }) => theme.gridUnit * 2}px;
}
`;
export const StyledFooterButton = styled(Button)`
width: ${({ theme }) => theme.gridUnit * 40}px;
`;
export const StyledStickyHeader = styled.div`
position: sticky;
top: 0;
z-index: ${({ theme }) => theme.zIndex.max};
background: ${({ theme }) => theme.colors.grayscale.light5};
`;

View File

@ -30,7 +30,17 @@ export type DatabaseObject = {
created_by?: null | DatabaseUser;
changed_on_delta_humanized?: string;
changed_on?: string;
parameters?: { database_name?: string };
parameters?: {
database_name?: string;
host?: string;
port?: number;
database?: string;
username?: string;
password?: string;
encryption?: boolean;
credentials_info?: string;
query?: string | object;
};
configuration_method: CONFIGURATION_METHOD;
engine?: string;
@ -49,10 +59,24 @@ export type DatabaseObject = {
// Security
encrypted_extra?: string;
server_cert?: string;
allow_csv_upload?: boolean;
impersonate_user?: boolean;
// Extra
impersonate_user?: boolean;
allow_csv_upload?: boolean;
extra_json?: {
engine_params?: {} | string;
metadata_params?: {} | string;
metadata_cache_timeout?: {
schema_cache_timeout?: number; // in Performance
table_cache_timeout?: number; // in Performance
}; // No field, holds schema and table timeout
allows_virtual_table_explore?: boolean; // in SQL Lab
schemas_allowed_for_csv_upload?: [] | string; // in Security
version?: string;
// todo: ask beto where this should live
cost_query_enabled?: boolean; // in SQL Lab
};
extra?: string;
};
@ -89,6 +113,11 @@ export type DatabaseForm = {
nullable: boolean;
type: string;
};
credentials_info: {
description: string;
nullable: boolean;
type: string;
};
};
required: string[];
type: string;

View File

@ -31,6 +31,7 @@ import { FilterValue } from 'src/components/ListView/types';
import Chart, { Slice } from 'src/types/Chart';
import copyTextToClipboard from 'src/utils/copy';
import { getClientErrorObject } from 'src/utils/getClientErrorObject';
import SupersetText from 'src/utils/textUtils';
import { FavoriteStatus, ImportResourceName, DatabaseObject } from './types';
interface ListViewResourceState<D extends object = any> {
@ -210,7 +211,7 @@ export function useListViewResource<D extends object = any>(
interface SingleViewResourceState<D extends object = any> {
loading: boolean;
resource: D | null;
error: string | Record<string, string[] | string> | null;
error: any | null;
}
export function useSingleViewResource<D extends object = any>(
@ -268,7 +269,7 @@ export function useSingleViewResource<D extends object = any>(
);
const createResource = useCallback(
(resource: D) => {
(resource: D, hideToast = false) => {
// Set loading state
updateState({
loading: true,
@ -288,13 +289,16 @@ export function useSingleViewResource<D extends object = any>(
return json.id;
},
createErrorHandler((errMsg: Record<string, string[] | string>) => {
handleErrorMsg(
t(
'An error occurred while creating %ss: %s',
resourceLabel,
parsedErrorMessage(errMsg),
),
);
// we did not want toasts for db-connection-ui but did not want to disable it everywhere
if (!hideToast) {
handleErrorMsg(
t(
'An error occurred while creating %ss: %s',
resourceLabel,
parsedErrorMessage(errMsg),
),
);
}
updateState({
error: errMsg,
@ -309,7 +313,7 @@ export function useSingleViewResource<D extends object = any>(
);
const updateResource = useCallback(
(resourceID: number, resource: D) => {
(resourceID: number, resource: D, hideToast = false) => {
// Set loading state
updateState({
loading: true,
@ -329,13 +333,15 @@ export function useSingleViewResource<D extends object = any>(
return json.result;
},
createErrorHandler(errMsg => {
handleErrorMsg(
t(
'An error occurred while fetching %ss: %s',
resourceLabel,
JSON.stringify(errMsg),
),
);
if (!hideToast) {
handleErrorMsg(
t(
'An error occurred while fetching %ss: %s',
resourceLabel,
JSON.stringify(errMsg),
),
);
}
updateState({
error: errMsg,
@ -595,6 +601,12 @@ export const copyQueryLink = (
});
};
export const getDatabaseImages = () => SupersetText.DB_IMAGES;
export const getConnectionAlert = () => SupersetText.DB_CONNECTION_ALERTS;
export const getDatabaseDocumentationLinks = () =>
SupersetText.DB_CONNECTION_DOC_LINKS;
export const testDatabaseConnection = (
connection: DatabaseObject,
handleErrorMsg: (errorMsg: string) => void,
@ -633,7 +645,7 @@ export function useDatabaseValidation() {
null,
);
const getValidation = useCallback(
(database: Partial<DatabaseObject> | null) => {
(database: Partial<DatabaseObject> | null, onCreate = false) => {
SupersetClient.post({
endpoint: '/api/v1/database/validate_parameters',
body: JSON.stringify(database),
@ -646,18 +658,23 @@ export function useDatabaseValidation() {
if (typeof e.json === 'function') {
e.json().then(({ errors = [] }: JsonObject) => {
const parsedErrors = errors
.filter(
(error: { error_type: string }) =>
error.error_type !== 'CONNECTION_MISSING_PARAMETERS_ERROR',
)
.filter((error: { error_type: string }) => {
const skipValidationError = ![
'CONNECTION_MISSING_PARAMETERS_ERROR',
'CONNECTION_ACCESS_DENIED_ERROR',
].includes(error.error_type);
return skipValidationError || onCreate;
})
.reduce(
(
obj: {},
{
error_type,
extra,
message,
}: {
extra: { invalid?: string[] };
error_type: string;
extra: { invalid?: string[]; missing?: string[] };
message: string;
},
) => {
@ -665,7 +682,23 @@ export function useDatabaseValidation() {
// error can't be mapped to a parameter
// so leave it alone
if (extra.invalid) {
return { ...obj, [extra.invalid[0]]: message };
return {
...obj,
[extra.invalid[0]]: message,
error_type,
};
}
if (extra.missing) {
return {
...obj,
error_type,
...Object.assign(
{},
...extra.missing.map(field => ({
[field]: 'This is a required field',
})),
),
};
}
return obj;
},
@ -682,5 +715,5 @@ export function useDatabaseValidation() {
[setValidationErrors],
);
return [validationErrors, getValidation] as const;
return [validationErrors, getValidation, setValidationErrors] as const;
}

View File

@ -1085,10 +1085,10 @@ SQL_VALIDATORS_BY_ENGINE = {
# use the "engine_name" attribute of the corresponding DB engine spec
# in `superset/db_engine_specs/`.
PREFERRED_DATABASES: List[str] = [
# "PostgreSQL",
# "Presto",
# "MySQL",
# "SQLite",
"PostgreSQL",
"Presto",
"MySQL",
"SQLite",
# etc.
]

View File

@ -246,6 +246,11 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
new_model = CreateDatabaseCommand(g.user, item).run()
# Return censored version for sqlalchemy URI
item["sqlalchemy_uri"] = new_model.sqlalchemy_uri
# If parameters are available return them in the payload
if new_model.parameters:
item["parameters"] = new_model.parameters
return self.response(201, id=new_model.id, result=item)
except DatabaseInvalidError as ex:
return self.response_422(message=ex.normalized_messages())

View File

@ -39,7 +39,7 @@ class DatabaseExistsValidationError(ValidationError):
def __init__(self) -> None:
super().__init__(
_("A database with the same name already exists"),
_("A database with the same name already exists."),
field_name="database_name",
)

View File

@ -35,6 +35,8 @@ from superset.db_engine_specs.base import BasicParametersMixin
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.models.core import Database
BYPASS_VALIDATION_ENGINES = {"bigquery"}
class ValidateDatabaseParametersCommand(BaseCommand):
def __init__(self, user: User, parameters: Dict[str, Any]):
@ -45,6 +47,11 @@ class ValidateDatabaseParametersCommand(BaseCommand):
def run(self) -> None:
engine = self._properties["engine"]
engine_specs = get_engine_specs()
if engine in BYPASS_VALIDATION_ENGINES:
# Skip engines that are only validated onCreate
return
if engine not in engine_specs:
raise InvalidEngineError(
SupersetError(
@ -78,9 +85,7 @@ class ValidateDatabaseParametersCommand(BaseCommand):
)
# perform initial validation
errors = engine_spec.validate_parameters(
self._properties.get("parameters", None)
)
errors = engine_spec.validate_parameters(self._properties.get("parameters", {}))
if errors:
raise InvalidParametersError(errors)

View File

@ -253,7 +253,12 @@ class DatabaseParametersSchemaMixin:
the constructed SQLAlchemy URI to be passed.
"""
parameters = data.pop("parameters", {})
engine = data.pop("engine", None)
# TODO(AAfghahi) standardize engine.
engine = (
data.pop("engine", None)
or parameters.pop("engine", None)
or data.pop("backend", None)
)
configuration_method = data.get("configuration_method")
if configuration_method == ConfigurationMethod.DYNAMIC_FORM:
@ -274,7 +279,7 @@ class DatabaseParametersSchemaMixin:
# validate parameters
parameters = engine_spec.parameters_schema.load(parameters) # type: ignore
serialized_encrypted_extra = data.get("encrypted_extra", "{}")
serialized_encrypted_extra = data.get("encrypted_extra") or "{}"
try:
encrypted_extra = json.loads(serialized_encrypted_extra)
except json.decoder.JSONDecodeError:
@ -306,6 +311,9 @@ def get_engine_spec(engine: Optional[str]) -> Type[BaseEngineSpec]:
class DatabaseValidateParametersSchema(Schema):
class Meta: # pylint: disable=too-few-public-methods
unknown = EXCLUDE
engine = fields.String(required=True, description="SQLAlchemy engine to use")
parameters = fields.Dict(
keys=fields.String(),
@ -330,21 +338,10 @@ class DatabaseValidateParametersSchema(Schema):
configuration_method = EnumField(
ConfigurationMethod,
by_value=True,
allow_none=True,
required=True,
description=configuration_method_description,
)
@validates_schema
def validate_parameters( # pylint: disable=no-self-use
self, data: Dict[str, Any], **kwargs: Any # pylint: disable=unused-argument
) -> None:
"""
Validate the DB engine spec specific parameters schema.
"""
# TODO (aafghahi): use a single parameter
engine_spec = get_engine_spec(data.get("engine") or data.get("backend"))
engine_spec.parameters_schema.load(data["parameters"]) # type: ignore
class DatabasePostSchema(Schema, DatabaseParametersSchemaMixin):
class Meta: # pylint: disable=too-few-public-methods

View File

@ -43,6 +43,7 @@ from apispec.ext.marshmallow import MarshmallowPlugin
from flask import current_app, g
from flask_babel import gettext as __, lazy_gettext as _
from marshmallow import fields, Schema
from marshmallow.validate import Range
from sqlalchemy import column, DateTime, select, types
from sqlalchemy.engine.base import Engine
from sqlalchemy.engine.interfaces import Compiled, Dialect
@ -1310,7 +1311,11 @@ class BasicParametersSchema(Schema):
username = fields.String(required=True, allow_none=True, description=__("Username"))
password = fields.String(allow_none=True, description=__("Password"))
host = fields.String(required=True, description=__("Hostname or IP address"))
port = fields.Integer(required=True, description=__("Database port"))
port = fields.Integer(
required=True,
description=__("Database port"),
validate=Range(min=0, max=2 ** 16, max_inclusive=False),
)
database = fields.String(required=True, description=__("Database name"))
query = fields.Dict(
keys=fields.Str(), values=fields.Raw(), description=__("Additional parameters")
@ -1443,7 +1448,30 @@ class BasicParametersMixin:
port = parameters.get("port", None)
if not port:
return errors
if not is_port_open(host, port):
try:
port = int(port)
except (ValueError, TypeError):
errors.append(
SupersetError(
message="Port must be a valid integer.",
error_type=SupersetErrorType.CONNECTION_INVALID_PORT_ERROR,
level=ErrorLevel.ERROR,
extra={"invalid": ["port"]},
),
)
if not (isinstance(port, int) and 0 <= port < 2 ** 16):
errors.append(
SupersetError(
message=(
"The port must be an integer between 0 and 65535 "
"(inclusive)."
),
error_type=SupersetErrorType.CONNECTION_INVALID_PORT_ERROR,
level=ErrorLevel.ERROR,
extra={"invalid": ["port"]},
),
)
elif not is_port_open(host, port):
errors.append(
SupersetError(
message="The port is closed.",

View File

@ -15,6 +15,7 @@
# specific language governing permissions and limitations
# under the License.
import re
import urllib
from datetime import datetime
from typing import Any, Dict, List, Optional, Pattern, Tuple, TYPE_CHECKING
@ -22,15 +23,16 @@ import pandas as pd
from apispec import APISpec
from apispec.ext.marshmallow import MarshmallowPlugin
from flask_babel import gettext as __
from marshmallow import Schema
from marshmallow import fields, Schema
from marshmallow.exceptions import ValidationError
from sqlalchemy import literal_column
from sqlalchemy.engine.url import make_url
from sqlalchemy.sql.expression import ColumnClause
from typing_extensions import TypedDict
from superset.databases.schemas import encrypted_field_properties, EncryptedField
from superset.db_engine_specs.base import BaseEngineSpec
from superset.errors import SupersetError, SupersetErrorType
from superset.exceptions import SupersetGenericDBErrorException
from superset.sql_parse import Table
from superset.utils import core as utils
from superset.utils.hashing import md5_sha_from_str
@ -67,12 +69,14 @@ ma_plugin = MarshmallowPlugin()
class BigQueryParametersSchema(Schema):
credentials_info = EncryptedField(
required=True, description="Contents of BigQuery JSON credentials.",
required=False, description="Contents of BigQuery JSON credentials.",
)
query = fields.Dict(required=False)
class BigQueryParametersType(TypedDict):
credentials_info: Dict[str, Any]
query: Dict[str, Any]
class BigQueryEngineSpec(BaseEngineSpec):
@ -354,29 +358,34 @@ class BigQueryEngineSpec(BaseEngineSpec):
@classmethod
def build_sqlalchemy_uri(
cls, _: BigQueryParametersType, encrypted_extra: Optional[Dict[str, Any]] = None
cls,
parameters: BigQueryParametersType,
encrypted_extra: Optional[Dict[str, Any]] = None,
) -> str:
if encrypted_extra:
project_id = encrypted_extra.get("credentials_info", {}).get("project_id")
query = parameters.get("query", {})
query_params = urllib.parse.urlencode(query)
if not encrypted_extra:
raise ValidationError("Missing service credentials")
project_id = encrypted_extra.get("credentials_info", {}).get("project_id")
if project_id:
return f"{cls.engine}+{cls.default_driver}://{project_id}"
return f"{cls.default_driver}://{project_id}/?{query_params}"
raise SupersetGenericDBErrorException(
message="Big Query encrypted_extra is not available.",
)
raise ValidationError("Invalid service credentials")
@classmethod
def get_parameters_from_uri(
cls, _: str, encrypted_extra: Optional[Dict[str, str]] = None
cls, uri: str, encrypted_extra: Optional[Dict[str, str]] = None
) -> Any:
# BigQuery doesn't have parameters
if encrypted_extra:
return encrypted_extra
value = make_url(uri)
raise SupersetGenericDBErrorException(
message="Big Query encrypted_extra is not available.",
)
# Building parameters from encrypted_extra and uri
if encrypted_extra:
return {**encrypted_extra, "query": value.query}
raise ValidationError("Invalid service credentials")
@classmethod
def validate_parameters(

View File

@ -28,7 +28,7 @@ from superset.utils import core as utils
class ElasticSearchEngineSpec(BaseEngineSpec): # pylint: disable=abstract-method
engine = "elasticsearch"
engine_name = "ElasticSearch"
engine_name = "ElasticSearch (SQL API)"
time_groupby_inline = True
time_secondary_columns = True
allows_joins = False
@ -83,7 +83,7 @@ class OpenDistroEngineSpec(BaseEngineSpec): # pylint: disable=abstract-method
}
engine = "odelasticsearch"
engine_name = "ElasticSearch"
engine_name = "ElasticSearch (OpenDistro SQL)"
@classmethod
def convert_dttm(cls, target_type: str, dttm: datetime) -> Optional[str]:

View File

@ -44,6 +44,7 @@ class SupersetErrorType(str, Enum):
CONNECTION_INVALID_PASSWORD_ERROR = "CONNECTION_INVALID_PASSWORD_ERROR"
CONNECTION_INVALID_HOSTNAME_ERROR = "CONNECTION_INVALID_HOSTNAME_ERROR"
CONNECTION_PORT_CLOSED_ERROR = "CONNECTION_PORT_CLOSED_ERROR"
CONNECTION_INVALID_PORT_ERROR = "CONNECTION_INVALID_PORT_ERROR"
CONNECTION_HOST_DOWN_ERROR = "CONNECTION_HOST_DOWN_ERROR"
CONNECTION_ACCESS_DENIED_ERROR = "CONNECTION_ACCESS_DENIED_ERROR"
CONNECTION_UNKNOWN_DATABASE_ERROR = "CONNECTION_UNKNOWN_DATABASE_ERROR"
@ -133,6 +134,7 @@ ISSUE_CODES = {
"The results stored in the backend were stored in a "
"different format, and no longer can be deserialized."
),
1034: _("The port number is invalid."),
1035: _("Failed to start remote query on a worker."),
1036: _("The database was deleted."),
}
@ -167,6 +169,7 @@ ERROR_TYPES_TO_ISSUE_CODES_MAPPING = {
SupersetErrorType.OBJECT_DOES_NOT_EXIST_ERROR: [1029],
SupersetErrorType.SYNTAX_ERROR: [1030],
SupersetErrorType.RESULTS_BACKEND_ERROR: [1031, 1032, 1033],
SupersetErrorType.CONNECTION_INVALID_PORT_ERROR: [1034],
SupersetErrorType.ASYNC_WORKERS_ERROR: [1035],
SupersetErrorType.DATABASE_NOT_FOUND_ERROR: [1011, 1036],
}

View File

@ -23,6 +23,7 @@ from collections import defaultdict
from io import BytesIO
from unittest import mock
from zipfile import is_zipfile, ZipFile
from operator import itemgetter
import prison
import pytest
@ -286,36 +287,33 @@ class TestDatabaseApi(SupersetTestCase):
}
assert rv.status_code == 400
# add this test back in when config method becomes required for creation.
# def test_create_database_no_configuration_method(self):
# """
# Database API: Test create with no config method.
# """
# extra = {
# "metadata_params": {},
# "engine_params": {},
# "metadata_cache_timeout": {},
# "schemas_allowed_for_csv_upload": [],
# }
def test_create_database_no_configuration_method(self):
"""
Database API: Test create with no config method.
"""
extra = {
"metadata_params": {},
"engine_params": {},
"metadata_cache_timeout": {},
"schemas_allowed_for_csv_upload": [],
}
# self.login(username="admin")
# example_db = get_example_database()
# if example_db.backend == "sqlite":
# return
# database_data = {
# "database_name": "test-create-database",
# "sqlalchemy_uri": example_db.sqlalchemy_uri_decrypted,
# "server_cert": None,
# "extra": json.dumps(extra),
# }
self.login(username="admin")
example_db = get_example_database()
if example_db.backend == "sqlite":
return
database_data = {
"database_name": "test-create-database",
"sqlalchemy_uri": example_db.sqlalchemy_uri_decrypted,
"server_cert": None,
"extra": json.dumps(extra),
}
# uri = "api/v1/database/"
# rv = self.client.post(uri, json=database_data)
# response = json.loads(rv.data.decode("utf-8"))
# assert response == {
# "message": {"configuration_method": ["Missing data for required field."]}
# }
# assert rv.status_code == 400
uri = "api/v1/database/"
rv = self.client.post(uri, json=database_data)
response = json.loads(rv.data.decode("utf-8"))
assert rv.status_code == 201
self.assertIn("sqlalchemy_form", response["result"]["configuration_method"])
def test_create_database_server_cert_validate(self):
"""
@ -430,7 +428,9 @@ class TestDatabaseApi(SupersetTestCase):
rv = self.client.post(uri, json=database_data)
response = json.loads(rv.data.decode("utf-8"))
expected_response = {
"message": {"database_name": "A database with the same name already exists"}
"message": {
"database_name": "A database with the same name already exists."
}
}
self.assertEqual(rv.status_code, 422)
self.assertEqual(response, expected_response)
@ -576,7 +576,9 @@ class TestDatabaseApi(SupersetTestCase):
rv = self.client.put(uri, json=database_data)
response = json.loads(rv.data.decode("utf-8"))
expected_response = {
"message": {"database_name": "A database with the same name already exists"}
"message": {
"database_name": "A database with the same name already exists."
}
}
self.assertEqual(rv.status_code, 422)
self.assertEqual(response, expected_response)
@ -1474,6 +1476,8 @@ class TestDatabaseApi(SupersetTestCase):
"port": {
"description": "Database port",
"format": "int32",
"maximum": 65536,
"minimum": 0,
"type": "integer",
},
"query": {
@ -1504,9 +1508,9 @@ class TestDatabaseApi(SupersetTestCase):
"description": "Contents of BigQuery JSON credentials.",
"type": "string",
"x-encrypted-extra": True,
}
},
"query": {"type": "object"},
},
"required": ["credentials_info"],
"type": "object",
},
"preferred": True,
@ -1539,6 +1543,8 @@ class TestDatabaseApi(SupersetTestCase):
"port": {
"description": "Database port",
"format": "int32",
"maximum": 65536,
"minimum": 0,
"type": "integer",
},
"query": {
@ -1585,6 +1591,8 @@ class TestDatabaseApi(SupersetTestCase):
"port": {
"description": "Database port",
"format": "int32",
"maximum": 65536,
"minimum": 0,
"type": "integer",
},
"query": {
@ -1679,6 +1687,7 @@ class TestDatabaseApi(SupersetTestCase):
response = json.loads(rv.data.decode("utf-8"))
assert rv.status_code == 422
response["errors"].sort(key=lambda error: error["extra"]["invalid"][0])
assert response == {
"errors": [
{
@ -1686,7 +1695,7 @@ class TestDatabaseApi(SupersetTestCase):
"error_type": "INVALID_PAYLOAD_SCHEMA_ERROR",
"level": "error",
"extra": {
"invalid": ["engine"],
"invalid": ["configuration_method"],
"issue_codes": [
{
"code": 1020,
@ -1696,11 +1705,11 @@ class TestDatabaseApi(SupersetTestCase):
},
},
{
"message": "Unknown field.",
"message": "Missing data for required field.",
"error_type": "INVALID_PAYLOAD_SCHEMA_ERROR",
"level": "error",
"extra": {
"invalid": ["foo"],
"invalid": ["engine"],
"issue_codes": [
{
"code": 1020,
@ -1716,6 +1725,7 @@ class TestDatabaseApi(SupersetTestCase):
self.login(username="admin")
url = "api/v1/database/validate_parameters"
payload = {
"configuration_method": ConfigurationMethod.SQLALCHEMY_FORM,
"engine": "postgresql",
"parameters": defaultdict(dict),
}
@ -1766,6 +1776,7 @@ class TestDatabaseApi(SupersetTestCase):
payload = {
"engine": "postgresql",
"parameters": defaultdict(dict),
"configuration_method": ConfigurationMethod.SQLALCHEMY_FORM,
}
payload["parameters"].update(
{
@ -1789,6 +1800,7 @@ class TestDatabaseApi(SupersetTestCase):
payload = {
"engine": "postgresql",
"parameters": defaultdict(dict),
"configuration_method": ConfigurationMethod.SQLALCHEMY_FORM,
}
payload["parameters"].update(
{
@ -1807,19 +1819,33 @@ class TestDatabaseApi(SupersetTestCase):
assert response == {
"errors": [
{
"message": "Not a valid integer.",
"error_type": "INVALID_PAYLOAD_SCHEMA_ERROR",
"message": "Port must be a valid integer.",
"error_type": "CONNECTION_INVALID_PORT_ERROR",
"level": "error",
"extra": {
"invalid": ["port"],
"issue_codes": [
{
"code": 1020,
"message": "Issue 1020 - The submitted payload has the incorrect schema.",
"code": 1034,
"message": "Issue 1034 - The port number is invalid.",
}
],
},
}
},
{
"message": "The port must be an integer between 0 and 65535 (inclusive).",
"error_type": "CONNECTION_INVALID_PORT_ERROR",
"level": "error",
"extra": {
"invalid": ["port"],
"issue_codes": [
{
"code": 1034,
"message": "Issue 1034 - The port number is invalid.",
}
],
},
},
]
}
@ -1832,6 +1858,7 @@ class TestDatabaseApi(SupersetTestCase):
payload = {
"engine": "postgresql",
"parameters": defaultdict(dict),
"configuration_method": ConfigurationMethod.SQLALCHEMY_FORM,
}
payload["parameters"].update(
{
@ -1879,3 +1906,61 @@ class TestDatabaseApi(SupersetTestCase):
},
]
}
@mock.patch("superset.db_engine_specs.base.is_hostname_valid")
def test_validate_parameters_invalid_port_range(self, is_hostname_valid):
is_hostname_valid.return_value = True
self.login(username="admin")
url = "api/v1/database/validate_parameters"
payload = {
"engine": "postgresql",
"parameters": defaultdict(dict),
"configuration_method": ConfigurationMethod.SQLALCHEMY_FORM,
}
payload["parameters"].update(
{
"host": "localhost",
"port": 65536,
"username": "",
"password": "",
"database": "",
"query": {},
}
)
rv = self.client.post(url, json=payload)
response = json.loads(rv.data.decode("utf-8"))
assert rv.status_code == 422
assert response == {
"errors": [
{
"message": "One or more parameters are missing: database, username",
"error_type": "CONNECTION_MISSING_PARAMETERS_ERROR",
"level": "warning",
"extra": {
"missing": ["database", "username"],
"issue_codes": [
{
"code": 1018,
"message": "Issue 1018 - One or more parameters needed to configure a database are missing.",
}
],
},
},
{
"message": "The port must be an integer between 0 and 65535 (inclusive).",
"error_type": "CONNECTION_INVALID_PORT_ERROR",
"level": "error",
"extra": {
"invalid": ["port"],
"issue_codes": [
{
"code": 1034,
"message": "Issue 1034 - The port number is invalid.",
}
],
},
},
]
}

View File

@ -470,24 +470,26 @@ def test_base_parameters_mixin():
assert json_schema == {
"type": "object",
"properties": {
"encryption": {
"type": "boolean",
"description": "Use an encrypted connection to the database",
},
"host": {"type": "string", "description": "Hostname or IP address"},
"database": {"type": "string", "description": "Database name"},
"port": {
"type": "integer",
"format": "int32",
"minimum": 0,
"maximum": 65536,
"description": "Database port",
},
"password": {"type": "string", "nullable": True, "description": "Password"},
"host": {"type": "string", "description": "Hostname or IP address"},
"username": {"type": "string", "nullable": True, "description": "Username"},
"query": {
"type": "object",
"description": "Additional parameters",
"additionalProperties": {},
},
"database": {"type": "string", "description": "Database name"},
"encryption": {
"type": "boolean",
"description": "Use an encrypted connection to the database",
},
},
"required": ["database", "host", "port", "username"],
}