Compare commits

...

14 Commits

Author SHA1 Message Date
Evan Rusackas
0dc4007586 fix: safe removal of tab with filters still scoped to a non-existing chart (#15650)
(cherry picked from commit 52ad779a27)
2021-07-13 10:41:27 -07:00
Hugh A. Miles II
551ad60c76 fix: Fix test connection for extra fields (#15645)
* create serialize json function

* remove console.log

* use function

(cherry picked from commit 2dc8bd6c30)
2021-07-13 10:41:27 -07:00
Hugh A. Miles II
a98949e2f9 fix: change sslmode to require for Postgres (#15642)
* change sslmode to require

* fix test

(cherry picked from commit f39582c900)
2021-07-13 10:41:27 -07:00
Hugh A. Miles II
be2ae92b08 fix: Remove default values for engine and schemas (#15635)
* remove default values

* don't set initial value on save

* set defaults for engine_params

* update with JSON.parse

(cherry picked from commit 2252f3396c)
2021-07-13 10:41:27 -07:00
Beto Dealmeida
2c963f1848 fix: duplicate DB names (#15614)
(cherry picked from commit 5d86ffe768)
2021-07-13 10:41:27 -07:00
simcha90
f0f0838ec5 fix(native-filters): Fix required filters (#15572)
* fix:fix get permission function

* fix: filters required state

* fix: fix CR notes

* fix: removre required message

* fix: fix validation state

(cherry picked from commit d70ac21054)
2021-07-13 10:41:27 -07:00
AAfghahi
52fe1bb0c8 clears errors when closing out of modal (#15623)
(cherry picked from commit 2ebba519c9)
2021-07-13 10:41:27 -07:00
AAfghahi
78e7d13ff9 fix: Database List Sorted (#15619)
* sorted the database list

* revisions

* cloned the array

* one more time with feeling

* added documentation link as well

(cherry picked from commit faf6fcd83e)
2021-07-13 10:41:27 -07:00
Hugh A. Miles II
5ce67b7666 update db for expose in sqllab param (#15609)
(cherry picked from commit f67e40236d)
2021-07-13 10:41:27 -07:00
Lyndsi Kay Williams
85d4359ac3 fix: Database Connection Modal - corrected tooltip alignment and info alert width (#15612)
* Added margins to info alerts

* Tooltips aligned

(cherry picked from commit b5fc03f964)
2021-07-13 10:41:27 -07:00
AAfghahi
bd629ec3ab added isEditMode (#15594)
(cherry picked from commit ad85e7be52)
2021-07-08 14:21:59 -07:00
Hugh A. Miles II
26cdcd0611 fix: DBC UI tooltip aligment (#15595)
(cherry picked from commit e539d08074)
2021-07-08 14:21:59 -07:00
Beto Dealmeida
cec5b4cdfd fix: available endpoint showing specs without drivers (#15587)
(cherry picked from commit 301b94f49a)
2021-07-08 14:21:59 -07:00
Phillip Kelley-Dotson
847e3f441a initial fix (#15581)
(cherry picked from commit 86a59a2927)
2021-07-08 14:21:59 -07:00
25 changed files with 246 additions and 205 deletions

View File

@@ -159,6 +159,8 @@ class HeaderActionsDropdown extends React.PureComponent {
downloadAsImage( downloadAsImage(
SCREENSHOT_NODE_SELECTOR, SCREENSHOT_NODE_SELECTOR,
this.props.dashboardTitle, this.props.dashboardTitle,
{},
true,
)(domEvent).then(() => { )(domEvent).then(() => {
menu.style.visibility = 'visible'; menu.style.visibility = 'visible';
}); });

View File

@@ -204,7 +204,7 @@ const FilterValue: React.FC<FilterProps> = ({
); );
const filterState = { const filterState = {
...filter.dataMask?.filterState, ...filter.dataMask?.filterState,
validateMessage: isMissingRequiredValue && t('Value is required'), validateStatus: isMissingRequiredValue && 'error',
}; };
if (filterState.value === undefined && preselection) { if (filterState.value === undefined && preselection) {
filterState.value = preselection; filterState.value = preselection;

View File

@@ -30,6 +30,7 @@ import { NativeFiltersForm } from '../types';
import { getFormData } from '../../utils'; import { getFormData } from '../../utils';
type DefaultValueProps = { type DefaultValueProps = {
hasDefaultValue: boolean;
filterId: string; filterId: string;
setDataMask: SetDataMaskHook; setDataMask: SetDataMaskHook;
hasDataset: boolean; hasDataset: boolean;
@@ -39,6 +40,7 @@ type DefaultValueProps = {
}; };
const DefaultValue: FC<DefaultValueProps> = ({ const DefaultValue: FC<DefaultValueProps> = ({
hasDefaultValue,
filterId, filterId,
hasDataset, hasDataset,
form, form,
@@ -59,8 +61,7 @@ const DefaultValue: FC<DefaultValueProps> = ({
}, [hasDataset, queriesData]); }, [hasDataset, queriesData]);
const value = formFilter.defaultDataMask?.filterState.value; const value = formFilter.defaultDataMask?.filterState.value;
const isMissingRequiredValue = const isMissingRequiredValue =
(value === null || value === undefined) && hasDefaultValue && (value === null || value === undefined);
formFilter?.controlValues?.enableEmptyFilter;
return loading ? ( return loading ? (
<Loading position="inline-centered" /> <Loading position="inline-centered" />
) : ( ) : (
@@ -80,6 +81,7 @@ const DefaultValue: FC<DefaultValueProps> = ({
filterState={{ filterState={{
...formFilter.defaultDataMask?.filterState, ...formFilter.defaultDataMask?.filterState,
validateMessage: isMissingRequiredValue && t('Value is required'), validateMessage: isMissingRequiredValue && t('Value is required'),
validateStatus: isMissingRequiredValue && 'error',
}} }}
/> />
); );

View File

@@ -756,64 +756,69 @@ const FiltersConfigForm = (
checked={hasDefaultValue} checked={hasDefaultValue}
onChange={value => setHasDefaultValue(value)} onChange={value => setHasDefaultValue(value)}
> >
<StyledRowSubFormItem {formFilter.filterType && (
name={['filters', filterId, 'defaultDataMask']} <StyledRowSubFormItem
initialValue={ name={['filters', filterId, 'defaultDataMask']}
formFilter.filterType === filterToEdit?.filterType initialValue={
? filterToEdit?.defaultDataMask formFilter.filterType === filterToEdit?.filterType
: null ? filterToEdit?.defaultDataMask
} : null
data-test="default-input" }
label={<StyledLabel>{t('Default Value')}</StyledLabel>} data-test="default-input"
required={hasDefaultValue} label={<StyledLabel>{t('Default Value')}</StyledLabel>}
rules={[ required={hasDefaultValue}
{ rules={[
validator: (rule, value) => { {
const hasValue = !!value?.filterState?.value; validator: (rule, value) => {
if (hasValue) { const hasValue =
return Promise.resolve(); value?.filterState?.value !== null &&
} value?.filterState?.value !== undefined;
return Promise.reject( if (hasValue) {
new Error(t('Default value is required')), return Promise.resolve();
); }
return Promise.reject(
new Error(t('Default value is required')),
);
},
}, },
}, ]}
]} >
> {error ? (
{error ? ( <BasicErrorAlert
<BasicErrorAlert title={t('Cannot load filter')}
title={t('Cannot load filter')} body={error}
body={error} level="error"
level="error"
/>
) : showDefaultValue ? (
<DefaultValueContainer>
<DefaultValue
setDataMask={dataMask => {
setNativeFilterFieldValues(form, filterId, {
defaultDataMask: dataMask,
});
form.validateFields([
['filters', filterId, 'defaultDataMask'],
]);
forceUpdate();
}}
filterId={filterId}
hasDataset={hasDataset}
form={form}
formData={newFormData}
enableNoResults={enableNoResults}
/> />
{hasDataset && datasetId && ( ) : showDefaultValue ? (
<Tooltip title={t('Refresh the default values')}> <DefaultValueContainer>
<RefreshIcon onClick={() => refreshHandler(true)} /> <DefaultValue
</Tooltip> setDataMask={dataMask => {
)} setNativeFilterFieldValues(form, filterId, {
</DefaultValueContainer> defaultDataMask: dataMask,
) : ( });
t('Fill all required fields to enable "Default Value"') form.validateFields([
)} ['filters', filterId, 'defaultDataMask'],
</StyledRowSubFormItem> ]);
forceUpdate();
}}
hasDefaultValue={hasDefaultValue}
filterId={filterId}
hasDataset={hasDataset}
form={form}
formData={newFormData}
enableNoResults={enableNoResults}
/>
{hasDataset && datasetId && (
<Tooltip title={t('Refresh the default values')}>
<RefreshIcon onClick={() => refreshHandler(true)} />
</Tooltip>
)}
</DefaultValueContainer>
) : (
t('Fill all required fields to enable "Default Value"')
)}
</StyledRowSubFormItem>
)}
</CollapsibleControl> </CollapsibleControl>
{Object.keys(controlItems) {Object.keys(controlItems)
.filter(key => BASIC_CONTROL_ITEMS.includes(key)) .filter(key => BASIC_CONTROL_ITEMS.includes(key))

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { useCallback, useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { FormInstance } from 'antd/lib/form'; import { FormInstance } from 'antd/lib/form';
import { t } from '@superset-ui/core'; import { t } from '@superset-ui/core';
import { NativeFiltersForm, NativeFiltersFormItem } from '../types'; import { NativeFiltersForm, NativeFiltersFormItem } from '../types';
@@ -52,27 +52,19 @@ export const useBackendFormUpdate = (
export const useDefaultValue = ( export const useDefaultValue = (
formFilter?: NativeFiltersFormItem, formFilter?: NativeFiltersFormItem,
filterToEdit?: Filter, filterToEdit?: Filter,
) => { ): [boolean, boolean, string, Function] => {
const [hasDefaultValue, setHasPartialDefaultValue] = useState( const enableEmptyFilter = !!formFilter?.controlValues?.enableEmptyFilter;
!!filterToEdit?.defaultDataMask?.filterState?.value, const defaultToFirstItem = !!formFilter?.controlValues?.defaultToFirstItem;
);
const [isRequired, setisRequired] = useState(
formFilter?.controlValues?.enableEmptyFilter,
);
const [hasDefaultValue, setHasPartialDefaultValue] = useState(false);
const [isRequired, setIsRequired] = useState(enableEmptyFilter);
const [defaultValueTooltip, setDefaultValueTooltip] = useState(''); const [defaultValueTooltip, setDefaultValueTooltip] = useState('');
const defaultToFirstItem = formFilter?.controlValues?.defaultToFirstItem; const setHasDefaultValue = (value = false) => {
const required = enableEmptyFilter && !defaultToFirstItem;
const setHasDefaultValue = useCallback( setIsRequired(required);
(value?) => { setHasPartialDefaultValue(required ? true : value);
const required = };
!!formFilter?.controlValues?.enableEmptyFilter && !defaultToFirstItem;
setisRequired(required);
setHasPartialDefaultValue(required ? true : value);
},
[formFilter?.controlValues?.enableEmptyFilter, defaultToFirstItem],
);
useEffect(() => { useEffect(() => {
setHasDefaultValue( setHasDefaultValue(
@@ -80,7 +72,16 @@ export const useDefaultValue = (
? false ? false
: !!formFilter?.defaultDataMask?.filterState?.value, : !!formFilter?.defaultDataMask?.filterState?.value,
); );
}, [setHasDefaultValue, defaultToFirstItem]); // TODO: this logic should be unhardcoded
}, [defaultToFirstItem, enableEmptyFilter]);
useEffect(() => {
setHasDefaultValue(
defaultToFirstItem
? false
: !!filterToEdit?.defaultDataMask?.filterState?.value,
);
}, []);
useEffect(() => { useEffect(() => {
let tooltip = ''; let tooltip = '';

View File

@@ -164,16 +164,6 @@ export function FiltersConfigModal({
addFilter, addFilter,
); );
// After this, it should be as if the modal was just opened fresh.
// Called when the modal is closed.
const resetForm = () => {
form.resetFields();
setNewFilterIds([]);
setCurrentFilterId(initialCurrentFilterId);
setRemovedFilters({});
setSaveAlertVisible(false);
};
const getFilterTitle = (id: string) => const getFilterTitle = (id: string) =>
formValues.filters[id]?.name ?? formValues.filters[id]?.name ??
filterConfigMap[id]?.name ?? filterConfigMap[id]?.name ??
@@ -209,7 +199,6 @@ export function FiltersConfigModal({
filterConfigMap, filterConfigMap,
filterIds, filterIds,
removedFilters, removedFilters,
resetForm,
onSave, onSave,
values, values,
)(); )();
@@ -219,7 +208,6 @@ export function FiltersConfigModal({
}; };
const handleConfirmCancel = () => { const handleConfirmCancel = () => {
resetForm();
onCancel(); onCancel();
}; };

View File

@@ -104,7 +104,6 @@ export const createHandleSave = (
filterConfigMap: Record<string, Filter>, filterConfigMap: Record<string, Filter>,
filterIds: string[], filterIds: string[],
removedFilters: Record<string, FilterRemoval>, removedFilters: Record<string, FilterRemoval>,
resetForm: Function,
saveForm: Function, saveForm: Function,
values: NativeFiltersForm, values: NativeFiltersForm,
) => async () => { ) => async () => {
@@ -145,7 +144,6 @@ export const createHandleSave = (
}); });
await saveForm(newFilterConfig); await saveForm(newFilterConfig);
resetForm();
}; };
export const createHandleTabEdit = ( export const createHandleTabEdit = (

View File

@@ -104,7 +104,7 @@ export const getAllActiveFilters = ({
}; };
// Iterate over all roots to find all affected charts // Iterate over all roots to find all affected charts
scope.rootPath.forEach((layoutItemId: string | number) => { scope.rootPath.forEach((layoutItemId: string | number) => {
layout[layoutItemId].children.forEach((child: string) => { layout[layoutItemId]?.children?.forEach((child: string) => {
// Need exclude from affected charts, charts that located in scope `excluded` // Need exclude from affected charts, charts that located in scope `excluded`
findAffectedCharts({ findAffectedCharts({
child, child,

View File

@@ -16,18 +16,15 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { ensureIsArray, ExtraFormData, styled, t, tn } from '@superset-ui/core'; import { ensureIsArray, ExtraFormData, t, tn } from '@superset-ui/core';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Select } from 'src/common/components'; import { Select } from 'src/common/components';
import { Styles, StyledSelect, StyledFormItem } from '../common'; import { FormItemProps } from 'antd/lib/form';
import { Styles, StyledSelect, StyledFormItem, StatusMessage } from '../common';
import { PluginFilterGroupByProps } from './types'; import { PluginFilterGroupByProps } from './types';
const { Option } = Select; const { Option } = Select;
const Error = styled.div`
color: ${({ theme }) => theme.colors.error.base};
`;
export default function PluginFilterGroupBy(props: PluginFilterGroupByProps) { export default function PluginFilterGroupBy(props: PluginFilterGroupByProps) {
const { const {
data, data,
@@ -84,11 +81,20 @@ export default function PluginFilterGroupBy(props: PluginFilterGroupByProps) {
columns.length === 0 columns.length === 0
? t('No columns') ? t('No columns')
: tn('%s option', '%s options', columns.length, columns.length); : tn('%s option', '%s options', columns.length, columns.length);
const formItemData: FormItemProps = {};
if (filterState.validateMessage) {
formItemData.extra = (
<StatusMessage status={filterState.validateStatus}>
{filterState.validateMessage}
</StatusMessage>
);
}
return ( return (
<Styles height={height} width={width}> <Styles height={height} width={width}>
<StyledFormItem <StyledFormItem
validateStatus={filterState.validateMessage && 'error'} validateStatus={filterState.validateStatus}
extra={<Error>{filterState.validateMessage}</Error>} {...formItemData}
> >
<StyledSelect <StyledSelect
allowClear allowClear

View File

@@ -25,47 +25,44 @@ import {
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Slider } from 'src/common/components'; import { Slider } from 'src/common/components';
import { rgba } from 'emotion-rgba'; import { rgba } from 'emotion-rgba';
import { FormItemProps } from 'antd/lib/form';
import { PluginFilterRangeProps } from './types'; import { PluginFilterRangeProps } from './types';
import { StyledFormItem, Styles } from '../common'; import { StatusMessage, StyledFormItem, Styles } from '../common';
import { getRangeExtraFormData } from '../../utils'; import { getRangeExtraFormData } from '../../utils';
const Error = styled.div` const Wrapper = styled.div<{ validateStatus?: 'error' | 'warning' | 'info' }>`
color: ${({ theme }) => theme.colors.error.base};
`;
const Wrapper = styled.div<{ validateStatus?: string }>`
border: 1px solid transparent; border: 1px solid transparent;
&:focus { &:focus {
border: 1px solid border: 1px solid
${({ theme, validateStatus }) => ${({ theme, validateStatus }) =>
theme.colors[validateStatus ? 'error' : 'primary'].base}; theme.colors[validateStatus || 'primary']?.base};
outline: 0; outline: 0;
box-shadow: 0 0 0 3px box-shadow: 0 0 0 3px
${({ theme, validateStatus }) => ${({ theme, validateStatus }) =>
rgba(theme.colors[validateStatus ? 'error' : 'primary'].base, 0.2)}; rgba(theme.colors[validateStatus || 'primary']?.base, 0.2)};
} }
& .ant-slider { & .ant-slider {
& .ant-slider-track { & .ant-slider-track {
background-color: ${({ theme, validateStatus }) => background-color: ${({ theme, validateStatus }) =>
validateStatus && theme.colors.error.light1}; validateStatus && theme.colors[validateStatus]?.light1};
} }
& .ant-slider-handle { & .ant-slider-handle {
border: ${({ theme, validateStatus }) => border: ${({ theme, validateStatus }) =>
validateStatus && `2px solid ${theme.colors.error.light1}`}; validateStatus && `2px solid ${theme.colors[validateStatus]?.light1}`};
&:focus { &:focus {
box-shadow: 0 0 0 3px box-shadow: 0 0 0 3px
${({ theme, validateStatus }) => ${({ theme, validateStatus }) =>
rgba(theme.colors[validateStatus ? 'error' : 'primary'].base, 0.2)}; rgba(theme.colors[validateStatus || 'primary']?.base, 0.2)};
} }
} }
&:hover { &:hover {
& .ant-slider-track { & .ant-slider-track {
background-color: ${({ theme, validateStatus }) => background-color: ${({ theme, validateStatus }) =>
validateStatus && theme.colors.error.base}; validateStatus && theme.colors[validateStatus]?.base};
} }
& .ant-slider-handle { & .ant-slider-handle {
border: ${({ theme, validateStatus }) => border: ${({ theme, validateStatus }) =>
validateStatus && `2px solid ${theme.colors.error.base}`}; validateStatus && `2px solid ${theme.colors[validateStatus]?.base}`};
} }
} }
} }
@@ -150,22 +147,31 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
}; };
useEffect(() => { useEffect(() => {
// when switch filter type and queriesData still not updated we need ignore this case (in FilterBar)
if (row?.min === undefined && row?.max === undefined) {
return;
}
handleAfterChange(filterState.value ?? [min, max]); handleAfterChange(filterState.value ?? [min, max]);
}, [JSON.stringify(filterState.value)]); }, [JSON.stringify(filterState.value), JSON.stringify(data)]);
const formItemData: FormItemProps = {};
if (filterState.validateMessage) {
formItemData.extra = (
<StatusMessage status={filterState.validateStatus}>
{filterState.validateMessage}
</StatusMessage>
);
}
return ( return (
<Styles height={height} width={width}> <Styles height={height} width={width}>
{Number.isNaN(Number(min)) || Number.isNaN(Number(max)) ? ( {Number.isNaN(Number(min)) || Number.isNaN(Number(max)) ? (
<h4>{t('Chosen non-numeric column')}</h4> <h4>{t('Chosen non-numeric column')}</h4>
) : ( ) : (
<StyledFormItem <StyledFormItem {...formItemData}>
validateStatus={filterState.validateMessage && 'error'}
extra={<Error>{filterState.validateMessage}</Error>}
>
<Wrapper <Wrapper
tabIndex={-1} tabIndex={-1}
ref={inputRef} ref={inputRef}
validateStatus={filterState.validateMessage} validateStatus={filterState.validateStatus}
onFocus={setFocusedFilter} onFocus={setFocusedFilter}
onBlur={unsetFocusedFilter} onBlur={unsetFocusedFilter}
onMouseEnter={setFocusedFilter} onMouseEnter={setFocusedFilter}

View File

@@ -26,7 +26,6 @@ import {
GenericDataType, GenericDataType,
JsonObject, JsonObject,
smartDateDetailedFormatter, smartDateDetailedFormatter,
styled,
t, t,
tn, tn,
} from '@superset-ui/core'; } from '@superset-ui/core';
@@ -44,16 +43,13 @@ import { SLOW_DEBOUNCE } from 'src/constants';
import { useImmerReducer } from 'use-immer'; import { useImmerReducer } from 'use-immer';
import Icons from 'src/components/Icons'; import Icons from 'src/components/Icons';
import { usePrevious } from 'src/common/hooks/usePrevious'; import { usePrevious } from 'src/common/hooks/usePrevious';
import { FormItemProps } from 'antd/lib/form';
import { PluginFilterSelectProps, SelectValue } from './types'; import { PluginFilterSelectProps, SelectValue } from './types';
import { StyledFormItem, StyledSelect, Styles } from '../common'; import { StyledFormItem, StyledSelect, Styles, StatusMessage } from '../common';
import { getDataRecordFormatter, getSelectExtraFormData } from '../../utils'; import { getDataRecordFormatter, getSelectExtraFormData } from '../../utils';
const { Option } = Select; const { Option } = Select;
const Error = styled.div`
color: ${({ theme }) => theme.colors.error.base};
`;
type DataMaskAction = type DataMaskAction =
| { type: 'ownState'; ownState: JsonObject } | { type: 'ownState'; ownState: JsonObject }
| { | {
@@ -152,6 +148,7 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
inverseSelection, inverseSelection,
), ),
filterState: { filterState: {
...filterState,
label: values?.length label: values?.length
? `${(values || []).join(', ')}${suffix}` ? `${(values || []).join(', ')}${suffix}`
: undefined, : undefined,
@@ -276,11 +273,20 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
: tn('%s option', '%s options', data.length, data.length); : tn('%s option', '%s options', data.length, data.length);
const Icon = inverseSelection ? Icons.StopOutlined : Icons.CheckOutlined; const Icon = inverseSelection ? Icons.StopOutlined : Icons.CheckOutlined;
const formItemData: FormItemProps = {};
if (filterState.validateMessage) {
formItemData.extra = (
<StatusMessage status={filterState.validateStatus}>
{filterState.validateMessage}
</StatusMessage>
);
}
return ( return (
<Styles height={height} width={width}> <Styles height={height} width={width}>
<StyledFormItem <StyledFormItem
validateStatus={filterState.validateMessage && 'error'} validateStatus={filterState.validateStatus}
extra={<Error>{filterState.validateMessage}</Error>} {...formItemData}
> >
<StyledSelect <StyledSelect
allowClear allowClear

View File

@@ -27,19 +27,23 @@ const TimeFilterStyles = styled(Styles)`
overflow-x: auto; overflow-x: auto;
`; `;
const ControlContainer = styled.div<{ validateStatus?: string }>` const ControlContainer = styled.div<{
validateStatus?: 'error' | 'warning' | 'info';
}>`
padding: 2px; padding: 2px;
& > span { & > span {
border: 2px solid transparent; border: 2px solid transparent;
display: inline-block; display: inline-block;
border: ${({ theme, validateStatus }) => border: ${({ theme, validateStatus }) =>
validateStatus && `2px solid ${theme.colors.error.base}`}; validateStatus && `2px solid ${theme.colors[validateStatus]?.base}`};
} }
&:focus { &:focus {
& > span { & > span {
border: 2px solid border: 2px solid
${({ theme, validateStatus }) => ${({ theme, validateStatus }) =>
validateStatus ? theme.colors.error.base : theme.colors.primary.base}; validateStatus
? theme.colors[validateStatus]?.base
: theme.colors.primary.base};
outline: 0; outline: 0;
box-shadow: 0 0 0 2px box-shadow: 0 0 0 2px
${({ validateStatus }) => ${({ validateStatus }) =>
@@ -85,7 +89,7 @@ export default function TimeFilterPlugin(props: PluginFilterTimeProps) {
<ControlContainer <ControlContainer
tabIndex={-1} tabIndex={-1}
ref={inputRef} ref={inputRef}
validateStatus={filterState.validateMessage} validateStatus={filterState.validateStatus}
onFocus={setFocusedFilter} onFocus={setFocusedFilter}
onBlur={unsetFocusedFilter} onBlur={unsetFocusedFilter}
onMouseEnter={setFocusedFilter} onMouseEnter={setFocusedFilter}

View File

@@ -20,21 +20,17 @@ import {
ensureIsArray, ensureIsArray,
ExtraFormData, ExtraFormData,
GenericDataType, GenericDataType,
styled,
t, t,
tn, tn,
} from '@superset-ui/core'; } from '@superset-ui/core';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Select } from 'src/common/components'; import { Select } from 'src/common/components';
import { Styles, StyledSelect, StyledFormItem } from '../common'; import { FormItemProps } from 'antd/lib/form';
import { Styles, StyledSelect, StyledFormItem, StatusMessage } from '../common';
import { PluginFilterTimeColumnProps } from './types'; import { PluginFilterTimeColumnProps } from './types';
const { Option } = Select; const { Option } = Select;
const Error = styled.div`
color: ${({ theme }) => theme.colors.error.base};
`;
export default function PluginFilterTimeColumn( export default function PluginFilterTimeColumn(
props: PluginFilterTimeColumnProps, props: PluginFilterTimeColumnProps,
) { ) {
@@ -86,11 +82,20 @@ export default function PluginFilterTimeColumn(
timeColumns.length === 0 timeColumns.length === 0
? t('No time columns') ? t('No time columns')
: tn('%s option', '%s options', timeColumns.length, timeColumns.length); : tn('%s option', '%s options', timeColumns.length, timeColumns.length);
const formItemData: FormItemProps = {};
if (filterState.validateMessage) {
formItemData.extra = (
<StatusMessage status={filterState.validateStatus}>
{filterState.validateMessage}
</StatusMessage>
);
}
return ( return (
<Styles height={height} width={width}> <Styles height={height} width={width}>
<StyledFormItem <StyledFormItem
validateStatus={filterState.validateMessage && 'error'} validateStatus={filterState.validateStatus}
extra={<Error>{filterState.validateMessage}</Error>} {...formItemData}
> >
<StyledSelect <StyledSelect
allowClear allowClear

View File

@@ -19,22 +19,18 @@
import { import {
ensureIsArray, ensureIsArray,
ExtraFormData, ExtraFormData,
styled,
t, t,
TimeGranularity, TimeGranularity,
tn, tn,
} from '@superset-ui/core'; } from '@superset-ui/core';
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { Select } from 'src/common/components'; import { Select } from 'src/common/components';
import { Styles, StyledSelect, StyledFormItem } from '../common'; import { FormItemProps } from 'antd/lib/form';
import { Styles, StyledSelect, StyledFormItem, StatusMessage } from '../common';
import { PluginFilterTimeGrainProps } from './types'; import { PluginFilterTimeGrainProps } from './types';
const { Option } = Select; const { Option } = Select;
const Error = styled.div`
color: ${({ theme }) => theme.colors.error.base};
`;
export default function PluginFilterTimegrain( export default function PluginFilterTimegrain(
props: PluginFilterTimeGrainProps, props: PluginFilterTimeGrainProps,
) { ) {
@@ -96,11 +92,20 @@ export default function PluginFilterTimegrain(
(data || []).length === 0 (data || []).length === 0
? t('No data') ? t('No data')
: tn('%s option', '%s options', data.length, data.length); : tn('%s option', '%s options', data.length, data.length);
const formItemData: FormItemProps = {};
if (filterState.validateMessage) {
formItemData.extra = (
<StatusMessage status={filterState.validateStatus}>
{filterState.validateMessage}
</StatusMessage>
);
}
return ( return (
<Styles height={height} width={width}> <Styles height={height} width={width}>
<StyledFormItem <StyledFormItem
validateStatus={filterState.validateMessage && 'error'} validateStatus={filterState.validateStatus}
extra={<Error>{filterState.validateMessage}</Error>} {...formItemData}
> >
<StyledSelect <StyledSelect
allowClear allowClear

View File

@@ -35,3 +35,9 @@ export const StyledFormItem = styled(FormItem)`
margin: 0; margin: 0;
} }
`; `;
export const StatusMessage = styled.div<{
status?: 'error' | 'warning' | 'info';
}>`
color: ${({ theme, status = 'error' }) => theme.colors[status]?.base};
`;

View File

@@ -360,7 +360,7 @@ const forceSSLField = ({
<InfoTooltip <InfoTooltip
tooltip={t('SSL Mode "require" will be used.')} tooltip={t('SSL Mode "require" will be used.')}
placement="right" placement="right"
viewBox="0 0 24 24" viewBox="0 -5 24 24"
/> />
</div> </div>
); );

View File

@@ -414,7 +414,7 @@ const ExtraOptions = ({
<div className="input-container"> <div className="input-container">
<StyledJsonEditor <StyledJsonEditor
name="metadata_params" name="metadata_params"
value={db?.extra_json?.metadata_params || '{}'} value={db?.extra_json?.metadata_params || ''}
placeholder={t('Metadata Parameters')} placeholder={t('Metadata Parameters')}
onChange={(json: string) => onChange={(json: string) =>
onExtraEditorChange({ json, name: 'metadata_params' }) onExtraEditorChange({ json, name: 'metadata_params' })
@@ -436,7 +436,7 @@ const ExtraOptions = ({
<div className="input-container"> <div className="input-container">
<StyledJsonEditor <StyledJsonEditor
name="engine_params" name="engine_params"
value={db?.extra_json?.engine_params || '{}'} value={db?.extra_json?.engine_params || ''}
placeholder={t('Engine Parameters')} placeholder={t('Engine Parameters')}
onChange={(json: string) => onChange={(json: string) =>
onExtraEditorChange({ json, name: 'engine_params' }) onExtraEditorChange({ json, name: 'engine_params' })

View File

@@ -27,11 +27,12 @@ import {
} from './styles'; } from './styles';
import { DatabaseForm, DatabaseObject } from '../types'; import { DatabaseForm, DatabaseObject } from '../types';
export const DOCUMENTATION_LINK =
'https://superset.apache.org/docs/databases/installing-database-drivers';
const supersetTextDocs = getDatabaseDocumentationLinks(); const supersetTextDocs = getDatabaseDocumentationLinks();
export const DOCUMENTATION_LINK = supersetTextDocs
? supersetTextDocs.support
: 'https://superset.apache.org/docs/databases/installing-database-drivers';
const irregularDocumentationLinks = { const irregularDocumentationLinks = {
postgresql: 'https://superset.apache.org/docs/databases/postgres', postgresql: 'https://superset.apache.org/docs/databases/postgres',
mssql: 'https://superset.apache.org/docs/databases/sql-server', mssql: 'https://superset.apache.org/docs/databases/sql-server',

View File

@@ -316,6 +316,15 @@ function dbReducer(
const DEFAULT_TAB_KEY = '1'; const DEFAULT_TAB_KEY = '1';
const serializeExtra = (extraJson: DatabaseObject['extra_json']) =>
JSON.stringify({
...extraJson,
metadata_params: JSON.parse((extraJson?.metadata_params as string) || '{}'),
engine_params: JSON.parse((extraJson?.engine_params as string) || '{}'),
schemas_allowed_for_csv_upload:
(extraJson?.schemas_allowed_for_csv_upload as string) || '[]',
});
const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
addDangerToast, addDangerToast,
addSuccessToast, addSuccessToast,
@@ -355,6 +364,7 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
fetchResource, fetchResource,
createResource, createResource,
updateResource, updateResource,
clearError,
} = useSingleViewResource<DatabaseObject>( } = useSingleViewResource<DatabaseObject>(
'database', 'database',
t('database'), t('database'),
@@ -387,7 +397,7 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
sqlalchemy_uri: db?.sqlalchemy_uri || '', sqlalchemy_uri: db?.sqlalchemy_uri || '',
database_name: db?.database_name?.trim() || undefined, database_name: db?.database_name?.trim() || undefined,
impersonate_user: db?.impersonate_user || undefined, impersonate_user: db?.impersonate_user || undefined,
extra: db?.extra || undefined, extra: serializeExtra(db?.extra_json) || undefined,
encrypted_extra: db?.encrypted_extra || '', encrypted_extra: db?.encrypted_extra || '',
server_cert: db?.server_cert || undefined, server_cert: db?.server_cert || undefined,
}; };
@@ -399,6 +409,7 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
setDB({ type: ActionType.reset }); setDB({ type: ActionType.reset });
setHasConnectedDb(false); setHasConnectedDb(false);
setValidationErrors(null); // reset validation errors on close setValidationErrors(null); // reset validation errors on close
clearError();
setEditNewDb(false); setEditNewDb(false);
onHide(); onHide();
}; };
@@ -458,18 +469,7 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
if (dbToUpdate?.extra_json) { if (dbToUpdate?.extra_json) {
// convert extra_json to back to string // convert extra_json to back to string
dbToUpdate.extra = JSON.stringify({ dbToUpdate.extra = serializeExtra(dbToUpdate?.extra_json);
...dbToUpdate.extra_json,
metadata_params: JSON.parse(
(dbToUpdate?.extra_json?.metadata_params as string) || '{}',
),
engine_params: JSON.parse(
(dbToUpdate?.extra_json?.engine_params as string) || '{}',
),
schemas_allowed_for_csv_upload:
(dbToUpdate?.extra_json?.schemas_allowed_for_csv_upload as string) ||
'[]',
});
} }
if (db?.id) { if (db?.id) {
@@ -530,16 +530,16 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
} }
}; };
const setDatabaseModel = (engine: string) => { const setDatabaseModel = (database_name: string) => {
const selectedDbModel = availableDbs?.databases.filter( const selectedDbModel = availableDbs?.databases.filter(
(db: DatabaseObject) => db.engine === engine, (db: DatabaseObject) => db.name === database_name,
)[0]; )[0];
const { name, parameters } = selectedDbModel; const { engine, parameters } = selectedDbModel;
const isDynamic = parameters !== undefined; const isDynamic = parameters !== undefined;
setDB({ setDB({
type: ActionType.dbSelected, type: ActionType.dbSelected,
payload: { payload: {
database_name: name, database_name,
configuration_method: isDynamic configuration_method: isDynamic
? CONFIGURATION_METHOD.DYNAMIC_FORM ? CONFIGURATION_METHOD.DYNAMIC_FORM
: CONFIGURATION_METHOD.SQLALCHEMY_URI, : CONFIGURATION_METHOD.SQLALCHEMY_URI,
@@ -559,12 +559,12 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
onChange={setDatabaseModel} onChange={setDatabaseModel}
placeholder="Choose a database..." placeholder="Choose a database..."
> >
{availableDbs?.databases {[...(availableDbs?.databases || [])]
?.sort((a: DatabaseForm, b: DatabaseForm) => ?.sort((a: DatabaseForm, b: DatabaseForm) =>
a.name.localeCompare(b.name), a.name.localeCompare(b.name),
) )
.map((database: DatabaseForm) => ( .map((database: DatabaseForm) => (
<Select.Option value={database.engine} key={database.engine}> <Select.Option value={database.name} key={database.name}>
{database.name} {database.name}
</Select.Option> </Select.Option>
))} ))}
@@ -618,7 +618,7 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
.map((database: DatabaseForm) => ( .map((database: DatabaseForm) => (
<IconButton <IconButton
className="preferred-item" className="preferred-item"
onClick={() => setDatabaseModel(database.engine)} onClick={() => setDatabaseModel(database.name)}
buttonText={database.name} buttonText={database.name}
icon={dbImages?.[database.engine]} icon={dbImages?.[database.engine]}
/> />
@@ -881,7 +881,7 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
testConnection={testConnection} testConnection={testConnection}
isEditMode={isEditMode} isEditMode={isEditMode}
/> />
{isDynamic(db?.backend || db?.engine) && ( {isDynamic(db?.backend || db?.engine) && !isEditMode && (
<div css={(theme: SupersetTheme) => infoTooltip(theme)}> <div css={(theme: SupersetTheme) => infoTooltip(theme)}>
<Button <Button
buttonStyle="link" buttonStyle="link"
@@ -904,7 +904,7 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
tooltip={t( tooltip={t(
'Click this link to switch to an alternate form that exposes only the required fields needed to connect this database.', 'Click this link to switch to an alternate form that exposes only the required fields needed to connect this database.',
)} )}
viewBox="0 -3 24 24" viewBox="0 -6 24 24"
/> />
</div> </div>
)} )}
@@ -934,29 +934,31 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
/> />
)} )}
{!isEditMode && ( {!isEditMode && (
<Alert <StyledAlertMargin>
closable={false} <Alert
css={(theme: SupersetTheme) => antDAlertStyles(theme)} closable={false}
message="Additional fields may be required" css={(theme: SupersetTheme) => antDAlertStyles(theme)}
showIcon message="Additional fields may be required"
description={ showIcon
<> description={
Select databases require additional fields to be completed in <>
the Advanced tab to successfully connect the database. Learn Select databases require additional fields to be completed
what requirements your databases has{' '} in the Advanced tab to successfully connect the database.
<a Learn what requirements your databases has{' '}
href={DOCUMENTATION_LINK} <a
target="_blank" href={DOCUMENTATION_LINK}
rel="noopener noreferrer" target="_blank"
className="additional-fields-alert-description" rel="noopener noreferrer"
> className="additional-fields-alert-description"
here >
</a> here
. </a>
</> .
} </>
type="info" }
/> type="info"
/>
</StyledAlertMargin>
)} )}
</Tabs.TabPane> </Tabs.TabPane>
<Tabs.TabPane tab={<span>{t('Advanced')}</span>} key="2"> <Tabs.TabPane tab={<span>{t('Advanced')}</span>} key="2">
@@ -1116,7 +1118,7 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
tooltip={t( tooltip={t(
'Click this link to switch to an alternate form that allows you to input the SQLAlchemy URL for this database manually.', 'Click this link to switch to an alternate form that allows you to input the SQLAlchemy URL for this database manually.',
)} )}
viewBox="6 4 24 24" viewBox="0 -6 24 24"
/> />
</div> </div>
{/* Step 2 */} {/* Step 2 */}

View File

@@ -124,7 +124,6 @@ export const antDModalNoPaddingStyles = css`
export const infoTooltip = (theme: SupersetTheme) => css` export const infoTooltip = (theme: SupersetTheme) => css`
margin-bottom: ${theme.gridUnit * 5}px; margin-bottom: ${theme.gridUnit * 5}px;
svg { svg {
vertical-align: bottom;
margin-bottom: ${theme.gridUnit * 0.25}px; margin-bottom: ${theme.gridUnit * 0.25}px;
} }
`; `;

View File

@@ -25,6 +25,7 @@ export type DatabaseObject = {
// Connection + general // Connection + general
id?: number; id?: number;
database_name: string; database_name: string;
name: string; // synonym to database_name
sqlalchemy_uri?: string; sqlalchemy_uri?: string;
backend?: string; backend?: string;
created_by?: null | DatabaseUser; created_by?: null | DatabaseUser;

View File

@@ -631,7 +631,7 @@ export function useAvailableDatabases() {
const getAvailable = useCallback(() => { const getAvailable = useCallback(() => {
SupersetClient.get({ SupersetClient.get({
endpoint: `/api/v1/database/available`, endpoint: `/api/v1/database/available/`,
}).then(({ json }) => { }).then(({ json }) => {
setAvailableDbs(json); setAvailableDbs(json);
}); });

View File

@@ -246,6 +246,7 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
new_model = CreateDatabaseCommand(g.user, item).run() new_model = CreateDatabaseCommand(g.user, item).run()
# Return censored version for sqlalchemy URI # Return censored version for sqlalchemy URI
item["sqlalchemy_uri"] = new_model.sqlalchemy_uri item["sqlalchemy_uri"] = new_model.sqlalchemy_uri
item["expose_in_sqllab"] = new_model.expose_in_sqllab
# If parameters are available return them in the payload # If parameters are available return them in the payload
if new_model.parameters: if new_model.parameters:
@@ -919,6 +920,9 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
preferred_databases: List[str] = app.config.get("PREFERRED_DATABASES", []) preferred_databases: List[str] = app.config.get("PREFERRED_DATABASES", [])
available_databases = [] available_databases = []
for engine_spec, drivers in get_available_engine_specs().items(): for engine_spec, drivers in get_available_engine_specs().items():
if not drivers:
continue
payload: Dict[str, Any] = { payload: Dict[str, Any] = {
"name": engine_spec.engine_name, "name": engine_spec.engine_name,
"engine": engine_spec.engine, "engine": engine_spec.engine,

View File

@@ -186,7 +186,7 @@ class PostgresEngineSpec(PostgresBaseEngineSpec, BasicParametersMixin):
"postgresql://user:password@host:port/dbname[?key=value&key=value...]" "postgresql://user:password@host:port/dbname[?key=value&key=value...]"
) )
# https://www.postgresql.org/docs/9.1/libpq-ssl.html#LIBQ-SSL-CERTIFICATES # https://www.postgresql.org/docs/9.1/libpq-ssl.html#LIBQ-SSL-CERTIFICATES
encryption_parameters = {"sslmode": "verify-ca"} encryption_parameters = {"sslmode": "require"}
max_column_name_length = 63 max_column_name_length = 63
try_remove_schema_from_table_name = False try_remove_schema_from_table_name = False

View File

@@ -460,7 +460,7 @@ def test_base_parameters_mixin():
) )
assert sqlalchemy_uri == ( assert sqlalchemy_uri == (
"postgresql+psycopg2://username:password@localhost:5432/dbname?" "postgresql+psycopg2://username:password@localhost:5432/dbname?"
"foo=bar&sslmode=verify-ca" "foo=bar&sslmode=require"
) )
parameters_from_uri = PostgresEngineSpec.get_parameters_from_uri(sqlalchemy_uri) parameters_from_uri = PostgresEngineSpec.get_parameters_from_uri(sqlalchemy_uri)