feat(native-filters): Show/Highlight errored/focused status (#15276)

* fix:fix get permission function

* feat: show error status for required filters

* test: fix tests

* refactor: fix CR notes
This commit is contained in:
simcha90 2021-06-22 14:25:08 +03:00 committed by GitHub
parent 19486780a2
commit 048609d120
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 320 additions and 90 deletions

View File

@ -58,6 +58,7 @@
"d3-color": "^1.2.0",
"d3-scale": "^2.1.2",
"dom-to-image": "^2.6.0",
"emotion-rgba": "0.0.9",
"fontsource-fira-code": "^3.0.5",
"fontsource-inter": "^3.0.5",
"geolib": "^2.0.24",
@ -25709,6 +25710,11 @@
"node": ">= 0.10"
}
},
"node_modules/emotion-rgba": {
"version": "0.0.9",
"resolved": "https://registry.npmjs.org/emotion-rgba/-/emotion-rgba-0.0.9.tgz",
"integrity": "sha512-fSt51Lh4a1fppXY3nQrMUC00p1jIYMSaRRkUhPiOJ3s9oumae1tY41AJytRK9d4YmJDP9njJBndgdDn9j7CbsA=="
},
"node_modules/emotion-theming": {
"version": "10.0.27",
"resolved": "https://registry.npmjs.org/emotion-theming/-/emotion-theming-10.0.27.tgz",
@ -76525,6 +76531,11 @@
"integrity": "sha1-TapNnbAPmBmIDHn6RXrlsJof04k=",
"dev": true
},
"emotion-rgba": {
"version": "0.0.9",
"resolved": "https://registry.npmjs.org/emotion-rgba/-/emotion-rgba-0.0.9.tgz",
"integrity": "sha512-fSt51Lh4a1fppXY3nQrMUC00p1jIYMSaRRkUhPiOJ3s9oumae1tY41AJytRK9d4YmJDP9njJBndgdDn9j7CbsA=="
},
"emotion-theming": {
"version": "10.0.27",
"resolved": "https://registry.npmjs.org/emotion-theming/-/emotion-theming-10.0.27.tgz",

View File

@ -110,6 +110,7 @@
"d3-color": "^1.2.0",
"d3-scale": "^2.1.2",
"dom-to-image": "^2.6.0",
"emotion-rgba": "0.0.9",
"fontsource-fira-code": "^3.0.5",
"fontsource-inter": "^3.0.5",
"geolib": "^2.0.24",

View File

@ -193,7 +193,7 @@ const FilterValue: React.FC<FilterProps> = ({
<Loading position="inline-centered" />
) : (
<SuperChart
height={20}
height={50}
width="100%"
formData={formData}
// For charts that don't have datasource we need workaround for empty placeholder
@ -203,7 +203,6 @@ const FilterValue: React.FC<FilterProps> = ({
filterState={{
...filter.dataMask?.filterState,
validateMessage: isMissingRequiredValue && t('Value is required'),
validateStatus: isMissingRequiredValue && 'error',
}}
ownState={filter.dataMask?.ownState}
enableNoResults={metadata?.enableNoResults}

View File

@ -22,6 +22,7 @@ import {
SetDataMaskHook,
SuperChart,
AppSection,
t,
} from '@superset-ui/core';
import { FormInstance } from 'antd/lib/form';
import Loading from 'src/components/Loading';
@ -56,7 +57,10 @@ const DefaultValue: FC<DefaultValueProps> = ({
setLoading(true);
}
}, [hasDataset, queriesData]);
const value = formFilter.defaultDataMask?.filterState.value;
const isMissingRequiredValue =
(value === null || value === undefined) &&
formFilter?.controlValues?.enableEmptyFilter;
return loading ? (
<Loading position="inline-centered" />
) : (
@ -73,7 +77,10 @@ const DefaultValue: FC<DefaultValueProps> = ({
chartType={formFilter?.filterType}
hooks={{ setDataMask }}
enableNoResults={enableNoResults}
filterState={formFilter.defaultDataMask?.filterState}
filterState={{
...formFilter.defaultDataMask?.filterState,
validateMessage: isMissingRequiredValue && t('Value is required'),
}}
/>
);
};

View File

@ -749,9 +749,7 @@ const FiltersConfigForm = (
if (hasValue) {
return Promise.resolve();
}
return Promise.reject(
new Error(t('Default value is required')),
);
return Promise.reject();
},
},
]}

View File

@ -191,13 +191,13 @@ test('renders a numerical range filter type', () => {
expect(screen.getByText(FILTER_NAME_REGEX)).toBeInTheDocument();
expect(screen.getByText(DATASET_REGEX)).toBeInTheDocument();
expect(screen.getByText(COLUMN_REGEX)).toBeInTheDocument();
expect(screen.getByText(REQUIRED_REGEX)).toBeInTheDocument();
expect(getCheckbox(DEFAULT_VALUE_REGEX)).not.toBeChecked();
expect(getCheckbox(APPLY_INSTANTLY_REGEX)).not.toBeChecked();
expect(getCheckbox(PRE_FILTER_REGEX)).not.toBeChecked();
expect(queryCheckbox(MULTIPLE_REGEX)).not.toBeInTheDocument();
expect(queryCheckbox(REQUIRED_REGEX)).not.toBeInTheDocument();
expect(queryCheckbox(HIERARCHICAL_REGEX)).not.toBeInTheDocument();
expect(queryCheckbox(FIRST_ITEM_REGEX)).not.toBeInTheDocument();
expect(queryCheckbox(INVERSE_SELECTION_REGEX)).not.toBeInTheDocument();

View File

@ -16,14 +16,19 @@
* specific language governing permissions and limitations
* under the License.
*/
import { ensureIsArray, ExtraFormData, t, tn } from '@superset-ui/core';
import { ensureIsArray, ExtraFormData, styled, t, tn } from '@superset-ui/core';
import React, { useEffect, useState } from 'react';
import { Select } from 'src/common/components';
import { Styles, StyledSelect } from '../common';
import { PluginFilterGroupByProps } from './types';
import FormItem from '../../../components/Form/FormItem';
const { Option } = Select;
const Error = styled.div`
color: ${({ theme }) => theme.colors.error.base};
`;
export default function PluginFilterGroupBy(props: PluginFilterGroupByProps) {
const {
data,
@ -70,28 +75,36 @@ export default function PluginFilterGroupBy(props: PluginFilterGroupByProps) {
: tn('%s option', '%s options', columns.length, columns.length);
return (
<Styles height={height} width={width}>
<StyledSelect
allowClear
value={value}
placeholder={placeholderText}
mode={multiSelect ? 'multiple' : undefined}
// @ts-ignore
onChange={handleChange}
onBlur={unsetFocusedFilter}
onFocus={setFocusedFilter}
ref={inputRef}
<FormItem
validateStatus={filterState.validateMessage && 'error'}
extra={<Error>{filterState.validateMessage}</Error>}
>
{columns.map(
(row: { column_name: string; verbose_name: string | null }) => {
const { column_name: columnName, verbose_name: verboseName } = row;
return (
<Option key={columnName} value={columnName}>
{verboseName ?? columnName}
</Option>
);
},
)}
</StyledSelect>
<StyledSelect
allowClear
value={value}
placeholder={placeholderText}
mode={multiSelect ? 'multiple' : undefined}
// @ts-ignore
onChange={handleChange}
onBlur={unsetFocusedFilter}
onFocus={setFocusedFilter}
ref={inputRef}
>
{columns.map(
(row: { column_name: string; verbose_name: string | null }) => {
const {
column_name: columnName,
verbose_name: verboseName,
} = row;
return (
<Option key={columnName} value={columnName}>
{verboseName ?? columnName}
</Option>
);
},
)}
</StyledSelect>
</FormItem>
</Styles>
);
}

View File

@ -44,6 +44,18 @@ const config: ControlPanelConfig = {
},
},
],
[
{
name: 'enableEmptyFilter',
config: {
type: 'CheckboxControl',
label: t('Required'),
default: false,
renderTrigger: true,
description: t('User must select a value for this filter.'),
},
},
],
],
},
],

View File

@ -16,12 +16,61 @@
* specific language governing permissions and limitations
* under the License.
*/
import { getNumberFormatter, NumberFormats, t } from '@superset-ui/core';
import {
getNumberFormatter,
NumberFormats,
styled,
t,
} from '@superset-ui/core';
import React, { useEffect, useState } from 'react';
import { Slider } from 'src/common/components';
import { rgba } from 'emotion-rgba';
import { PluginFilterRangeProps } from './types';
import { Styles } from '../common';
import { getRangeExtraFormData } from '../../utils';
import FormItem from '../../../components/Form/FormItem';
const Error = styled.div`
color: ${({ theme }) => theme.colors.error.base};
`;
const Wrapper = styled.div<{ validateStatus?: string }>`
border: 1px solid transparent;
&:focus {
border: 1px solid
${({ theme, validateStatus }) =>
theme.colors[validateStatus ? 'error' : 'primary'].base};
outline: 0;
box-shadow: 0 0 0 3px
${({ theme, validateStatus }) =>
rgba(theme.colors[validateStatus ? 'error' : 'primary'].base, 0.2)};
}
& .ant-slider {
& .ant-slider-track {
background-color: ${({ theme, validateStatus }) =>
validateStatus && theme.colors.error.light1};
}
& .ant-slider-handle {
border: ${({ theme, validateStatus }) =>
validateStatus && `2px solid ${theme.colors.error.light1}`};
&:focus {
box-shadow: 0 0 0 3px
${({ theme, validateStatus }) =>
rgba(theme.colors[validateStatus ? 'error' : 'primary'].base, 0.2)};
}
}
&:hover {
& .ant-slider-track {
background-color: ${({ theme, validateStatus }) =>
validateStatus && theme.colors.error.base};
}
& .ant-slider-handle {
border: ${({ theme, validateStatus }) =>
validateStatus && `2px solid ${theme.colors.error.base}`};
}
}
}
`;
export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
const {
@ -32,7 +81,6 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
setDataMask,
setFocusedFilter,
unsetFocusedFilter,
inputRef,
filterState,
} = props;
const numberFormatter = getNumberFormatter(NumberFormats.SMART_NUMBER);
@ -40,7 +88,7 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
const [row] = data;
// @ts-ignore
const { min, max }: { min: number; max: number } = row;
const { groupby, defaultValue } = formData;
const { groupby, defaultValue, inputRef } = formData;
const [col = ''] = groupby || [];
const [value, setValue] = useState<[number, number]>(
defaultValue ?? [min, max],
@ -111,19 +159,31 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
{Number.isNaN(Number(min)) || Number.isNaN(Number(max)) ? (
<h4>{t('Chosen non-numeric column')}</h4>
) : (
<div onMouseEnter={setFocusedFilter} onMouseLeave={unsetFocusedFilter}>
<Slider
range
min={min}
max={max}
value={value ?? [min, max]}
onAfterChange={handleAfterChange}
onChange={handleChange}
tipFormatter={value => numberFormatter(value)}
<FormItem
validateStatus={filterState.validateMessage && 'error'}
extra={<Error>{filterState.validateMessage}</Error>}
>
<Wrapper
tabIndex={-1}
ref={inputRef}
marks={marks}
/>
</div>
validateStatus={filterState.validateMessage}
onFocus={setFocusedFilter}
onBlur={unsetFocusedFilter}
onMouseEnter={setFocusedFilter}
onMouseLeave={unsetFocusedFilter}
>
<Slider
range
min={min}
max={max}
value={value ?? [min, max]}
onAfterChange={handleAfterChange}
onChange={handleChange}
tipFormatter={value => numberFormatter(value)}
marks={marks}
/>
</Wrapper>
</FormItem>
)}
</Styles>
);

View File

@ -43,6 +43,24 @@ const config: ControlPanelConfig = {
],
],
},
{
label: t('UI Configuration'),
expanded: true,
controlSetRows: [
[
{
name: 'enableEmptyFilter',
config: {
type: 'CheckboxControl',
label: t('Required'),
default: false,
renderTrigger: true,
description: t('User must select a value for this filter.'),
},
},
],
],
},
],
};

View File

@ -280,7 +280,7 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
return (
<Styles height={height} width={width}>
<FormItem
validateStatus={filterState.validateStatus}
validateStatus={filterState.validateMessage && 'error'}
extra={<Error>{filterState.validateMessage}</Error>}
>
<StyledSelect

View File

@ -27,8 +27,27 @@ const TimeFilterStyles = styled(Styles)`
overflow-x: auto;
`;
const ControlContainer = styled.div`
display: inline-block;
const ControlContainer = styled.div<{ validateStatus?: string }>`
padding: 2px;
& > span {
border: 2px solid transparent;
display: inline-block;
border: ${({ theme, validateStatus }) =>
validateStatus && `2px solid ${theme.colors.error.base}`};
}
&:focus {
& > span {
border: 2px solid
${({ theme, validateStatus }) =>
validateStatus ? theme.colors.error.base : theme.colors.primary.base};
outline: 0;
box-shadow: 0 0 0 2px
${({ validateStatus }) =>
validateStatus
? 'rgba(224, 67, 85, 12%)'
: 'rgba(32, 167, 201, 0.2)'};
}
}
`;
export default function TimeFilterPlugin(props: PluginFilterTimeProps) {
@ -37,7 +56,9 @@ export default function TimeFilterPlugin(props: PluginFilterTimeProps) {
setFocusedFilter,
unsetFocusedFilter,
width,
height,
filterState,
formData: { inputRef },
} = props;
const handleTimeRangeChange = (timeRange?: string): void => {
@ -60,8 +81,13 @@ export default function TimeFilterPlugin(props: PluginFilterTimeProps) {
return (
// @ts-ignore
<TimeFilterStyles width={width}>
<TimeFilterStyles width={width} height={height}>
<ControlContainer
tabIndex={-1}
ref={inputRef}
validateStatus={filterState.validateMessage}
onFocus={setFocusedFilter}
onBlur={unsetFocusedFilter}
onMouseEnter={setFocusedFilter}
onMouseLeave={unsetFocusedFilter}
>

View File

@ -17,10 +17,30 @@
* under the License.
*/
import { ControlPanelConfig } from '@superset-ui/chart-controls';
import { t } from '@superset-ui/core';
const config: ControlPanelConfig = {
// For control input types, see: superset-frontend/src/explore/components/controls/index.js
controlPanelSections: [],
controlPanelSections: [
{
label: t('UI Configuration'),
expanded: true,
controlSetRows: [
[
{
name: 'enableEmptyFilter',
config: {
type: 'CheckboxControl',
label: t('Required'),
default: false,
renderTrigger: true,
description: t('User must select a value for this filter.'),
},
},
],
],
},
],
};
export default config;

View File

@ -20,6 +20,7 @@ import {
ensureIsArray,
ExtraFormData,
GenericDataType,
styled,
t,
tn,
} from '@superset-ui/core';
@ -27,9 +28,14 @@ import React, { useEffect, useState } from 'react';
import { Select } from 'src/common/components';
import { Styles, StyledSelect } from '../common';
import { PluginFilterTimeColumnProps } from './types';
import FormItem from '../../../components/Form/FormItem';
const { Option } = Select;
const Error = styled.div`
color: ${({ theme }) => theme.colors.error.base};
`;
export default function PluginFilterTimeColumn(
props: PluginFilterTimeColumnProps,
) {
@ -83,27 +89,35 @@ export default function PluginFilterTimeColumn(
: tn('%s option', '%s options', timeColumns.length, timeColumns.length);
return (
<Styles height={height} width={width}>
<StyledSelect
allowClear
value={value}
placeholder={placeholderText}
// @ts-ignore
onChange={handleChange}
onBlur={unsetFocusedFilter}
onFocus={setFocusedFilter}
ref={inputRef}
<FormItem
validateStatus={filterState.validateMessage && 'error'}
extra={<Error>{filterState.validateMessage}</Error>}
>
{timeColumns.map(
(row: { column_name: string; verbose_name: string | null }) => {
const { column_name: columnName, verbose_name: verboseName } = row;
return (
<Option key={columnName} value={columnName}>
{verboseName ?? columnName}
</Option>
);
},
)}
</StyledSelect>
<StyledSelect
allowClear
value={value}
placeholder={placeholderText}
// @ts-ignore
onChange={handleChange}
onBlur={unsetFocusedFilter}
onFocus={setFocusedFilter}
ref={inputRef}
>
{timeColumns.map(
(row: { column_name: string; verbose_name: string | null }) => {
const {
column_name: columnName,
verbose_name: verboseName,
} = row;
return (
<Option key={columnName} value={columnName}>
{verboseName ?? columnName}
</Option>
);
},
)}
</StyledSelect>
</FormItem>
</Styles>
);
}

View File

@ -17,9 +17,29 @@
* under the License.
*/
import { ControlPanelConfig } from '@superset-ui/chart-controls';
import { t } from '@superset-ui/core';
const config: ControlPanelConfig = {
controlPanelSections: [],
controlPanelSections: [
{
label: t('UI Configuration'),
expanded: true,
controlSetRows: [
[
{
name: 'enableEmptyFilter',
config: {
type: 'CheckboxControl',
label: t('Required'),
default: false,
renderTrigger: true,
description: t('User must select a value for this filter.'),
},
},
],
],
},
],
};
export default config;

View File

@ -19,6 +19,7 @@
import {
ensureIsArray,
ExtraFormData,
styled,
t,
TimeGranularity,
tn,
@ -27,9 +28,14 @@ import React, { useEffect, useState } from 'react';
import { Select } from 'src/common/components';
import { Styles, StyledSelect } from '../common';
import { PluginFilterTimeGrainProps } from './types';
import FormItem from '../../../components/Form/FormItem';
const { Option } = Select;
const Error = styled.div`
color: ${({ theme }) => theme.colors.error.base};
`;
export default function PluginFilterTimegrain(
props: PluginFilterTimeGrainProps,
) {
@ -80,25 +86,30 @@ export default function PluginFilterTimegrain(
: tn('%s option', '%s options', data.length, data.length);
return (
<Styles height={height} width={width}>
<StyledSelect
allowClear
value={value}
placeholder={placeholderText}
// @ts-ignore
onChange={handleChange}
onBlur={unsetFocusedFilter}
onFocus={setFocusedFilter}
ref={inputRef}
<FormItem
validateStatus={filterState.validateMessage && 'error'}
extra={<Error>{filterState.validateMessage}</Error>}
>
{(data || []).map((row: { name: string; duration: string }) => {
const { name, duration } = row;
return (
<Option key={duration} value={duration}>
{name}
</Option>
);
})}
</StyledSelect>
<StyledSelect
allowClear
value={value}
placeholder={placeholderText}
// @ts-ignore
onChange={handleChange}
onBlur={unsetFocusedFilter}
onFocus={setFocusedFilter}
ref={inputRef}
>
{(data || []).map((row: { name: string; duration: string }) => {
const { name, duration } = row;
return (
<Option key={duration} value={duration}>
{name}
</Option>
);
})}
</StyledSelect>
</FormItem>
</Styles>
);
}

View File

@ -17,9 +17,29 @@
* under the License.
*/
import { ControlPanelConfig } from '@superset-ui/chart-controls';
import { t } from '@superset-ui/core';
const config: ControlPanelConfig = {
controlPanelSections: [],
controlPanelSections: [
{
label: t('UI Configuration'),
expanded: true,
controlSetRows: [
[
{
name: 'enableEmptyFilter',
config: {
type: 'CheckboxControl',
label: t('Required'),
default: false,
renderTrigger: true,
description: t('User must select a value for this filter.'),
},
},
],
],
},
],
};
export default config;