feat: Database Connection UI (#14881)
This commit is contained in:
parent
7f2f51b2bd
commit
d4480f5c9a
|
|
@ -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
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
}
|
||||
`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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¶m2=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>
|
||||
|
|
|
|||
|
|
@ -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 database’s version. This should be used with ' +
|
||||
'Presto databases so that the syntax is correct.',
|
||||
)}
|
||||
</div>
|
||||
</StyledInputContainer>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -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};
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue