feat: Add single select and inverse selection to numeric range (#16722) (#17372)

* feat: add single select and inverse selection to numeric range (#16722)

* Ignore invalid eslint errors regarding conditionally called hooks.

* Add license header to new file.

* Flipped the numerical range values for the minimum slider so that the highlighted range value accurately reflects the applied filter.

* Resolved linting errors

* Remove unnecessary important flag from css
This commit is contained in:
Matt Houston 2021-12-01 17:28:11 -06:00 committed by GitHub
parent f949c8ed7a
commit 54b56fe12f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 332 additions and 29 deletions

View File

@ -16,6 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
/* eslint-disable react-hooks/rules-of-hooks */
import {
ColumnMeta,
InfoTooltipWithTrigger,
@ -35,7 +36,7 @@ import {
t,
} from '@superset-ui/core';
import { FormInstance } from 'antd/lib/form';
import { isEmpty, isEqual } from 'lodash';
import { isEqual } from 'lodash';
import React, {
forwardRef,
useCallback,
@ -73,6 +74,7 @@ import {
Filter,
NativeFilterType,
} from 'src/dashboard/components/nativeFilters/types';
import { SingleValueType } from 'src/filters/components/Range/SingleValueType';
import { getFormData } from 'src/dashboard/components/nativeFilters/utils';
import {
CASCADING_FILTERS,
@ -543,6 +545,15 @@ const FiltersConfigForm = (
!!filterToEdit?.adhoc_filters?.length ||
!!filterToEdit?.time_range;
const hasEnableSingleValue =
formFilter?.controlValues?.enableSingleValue !== undefined ||
filterToEdit?.controlValues?.enableSingleValue !== undefined;
let enableSingleValue = filterToEdit?.controlValues?.enableSingleValue;
if (formFilter?.controlValues?.enableSingleMaxValue !== undefined) {
({ enableSingleValue } = formFilter.controlValues);
}
const hasSorting =
typeof formFilter?.controlValues?.sortAscending === 'boolean' ||
typeof filterToEdit?.controlValues?.sortAscending === 'boolean';
@ -568,6 +579,17 @@ const FiltersConfigForm = (
forceUpdate();
};
const onEnableSingleValueChanged = (value: SingleValueType | undefined) => {
const previous = form.getFieldValue('filters')?.[filterId].controlValues;
setNativeFilterFieldValues(form, filterId, {
controlValues: {
...previous,
enableSingleValue: value,
},
});
forceUpdate();
};
const validatePreFilter = () =>
setTimeout(
() =>
@ -669,12 +691,13 @@ const FiltersConfigForm = (
]);
useEffect(() => {
// Run only once when the control items are available
if (isActive && !isEmpty(controlItems)) {
// Run only once
if (isActive) {
const hasCheckedAdvancedControl =
hasParentFilter ||
hasPreFilter ||
hasSorting ||
hasEnableSingleValue ||
Object.keys(controlItems)
.filter(key => !BASIC_CONTROL_ITEMS.includes(key))
.some(key => controlItems[key].checked);
@ -1137,7 +1160,7 @@ const FiltersConfigForm = (
</CollapsibleControl>
</CleanFormItem>
)}
{formFilter?.filterType !== 'filter_range' && (
{formFilter?.filterType !== 'filter_range' ? (
<CleanFormItem name={['filters', filterId, 'sortFilter']}>
<CollapsibleControl
initialValue={hasSorting}
@ -1204,6 +1227,48 @@ const FiltersConfigForm = (
)}
</CollapsibleControl>
</CleanFormItem>
) : (
<CleanFormItem name={['filters', filterId, 'rangeFilter']}>
<CollapsibleControl
initialValue={hasEnableSingleValue}
title={t('Single Value')}
onChange={checked => {
onEnableSingleValueChanged(
checked ? SingleValueType.Exact : undefined,
);
formChanged();
}}
>
<StyledRowFormItem
name={[
'filters',
filterId,
'controlValues',
'enableSingleValue',
]}
initialValue={enableSingleValue}
label={
<StyledLabel>{t('Single value type')}</StyledLabel>
}
>
<Radio.Group
onChange={value =>
onEnableSingleValueChanged(value.target.value)
}
>
<Radio value={SingleValueType.Minimum}>
{t('Minimum')}
</Radio>
<Radio value={SingleValueType.Exact}>
{t('Exact')}
</Radio>
<Radio value={SingleValueType.Maximum}>
{t('Maximum')}
</Radio>
</Radio.Group>
</StyledRowFormItem>
</CollapsibleControl>
</CleanFormItem>
)}
</Collapse.Panel>
)}

View File

@ -125,6 +125,16 @@ test('Should render null empty when "getControlItems" return []', () => {
expect(container.children).toHaveLength(0);
});
test('Should render null empty when "getControlItems" return enableSingleValue', () => {
const props = createProps();
(getControlItems as jest.Mock).mockReturnValue([
{ name: 'enableSingleValue', config: { renderTrigger: true } },
]);
const controlItemsMap = getControlItemsMap(props);
const { container } = renderControlItems(controlItemsMap);
expect(container.children).toHaveLength(0);
});
test('Should render null empty when "controlItems" are falsy', () => {
const props = createProps();
const controlItems = [null, false, {}, { config: { renderTrigger: false } }];

View File

@ -145,7 +145,8 @@ export default function getControlItemsMap({
.filter(
(controlItem: CustomControlItem) =>
controlItem?.config?.renderTrigger &&
controlItem.name !== 'sortAscending',
controlItem.name !== 'sortAscending' &&
controlItem.name !== 'enableSingleValue',
)
.forEach(controlItem => {
const initialValue =

View File

@ -20,6 +20,7 @@ import { AppSection, GenericDataType } from '@superset-ui/core';
import React from 'react';
import { render } from 'spec/helpers/testing-library';
import RangeFilterPlugin from './RangeFilterPlugin';
import { SingleValueType } from './SingleValueType';
import transformProps from './transformProps';
const rangeProps = {
@ -118,4 +119,61 @@ describe('RangeFilterPlugin', () => {
},
});
});
it('should call setDataMask with correct greater than filter', () => {
getWrapper({ enableSingleValue: SingleValueType.Minimum });
expect(setDataMask).toHaveBeenCalledWith({
extraFormData: {
filters: [
{
col: 'SP_POP_TOTL',
op: '>=',
val: 70,
},
],
},
filterState: {
label: 'x ≥ 70',
value: [70, 100],
},
});
});
it('should call setDataMask with correct less than filter', () => {
getWrapper({ enableSingleValue: SingleValueType.Maximum });
expect(setDataMask).toHaveBeenCalledWith({
extraFormData: {
filters: [
{
col: 'SP_POP_TOTL',
op: '<=',
val: 70,
},
],
},
filterState: {
label: 'x ≤ 70',
value: [10, 70],
},
});
});
it('should call setDataMask with correct exact filter', () => {
getWrapper({ enableSingleValue: SingleValueType.Exact });
expect(setDataMask).toHaveBeenCalledWith({
extraFormData: {
filters: [
{
col: 'SP_POP_TOTL',
op: '==',
val: 10,
},
],
},
filterState: {
label: 'x = 10',
value: [10, 10],
},
});
});
});

View File

@ -30,6 +30,40 @@ import { rgba } from 'emotion-rgba';
import { PluginFilterRangeProps } from './types';
import { StatusMessage, StyledFormItem, FilterPluginStyle } from '../common';
import { getRangeExtraFormData } from '../../utils';
import { SingleValueType } from './SingleValueType';
const LIGHT_BLUE = '#99e7f0';
const DARK_BLUE = '#6dd3e3';
const LIGHT_GRAY = '#f5f5f5';
const DARK_GRAY = '#e1e1e1';
const StyledMinSlider = styled(Slider)<{
validateStatus?: 'error' | 'warning' | 'info';
}>`
${({ theme, validateStatus }) => `
.ant-slider-rail {
background-color: ${
validateStatus ? theme.colors[validateStatus]?.light1 : LIGHT_BLUE
};
}
.ant-slider-track {
background-color: ${LIGHT_GRAY};
}
&:hover {
.ant-slider-rail {
background-color: ${
validateStatus ? theme.colors[validateStatus]?.base : DARK_BLUE
};
}
.ant-slider-track {
background-color: ${DARK_GRAY};
}
}
`}
`;
const Wrapper = styled.div<{ validateStatus?: 'error' | 'warning' | 'info' }>`
${({ theme, validateStatus }) => `
@ -80,6 +114,9 @@ const numberFormatter = getNumberFormatter(NumberFormats.SMART_NUMBER);
const tipFormatter = (value: number) => numberFormatter(value);
const getLabel = (lower: number | null, upper: number | null): string => {
if (lower !== null && upper !== null && lower === upper) {
return `x = ${numberFormatter(lower)}`;
}
if (lower !== null && upper !== null) {
return `${numberFormatter(lower)} ≤ x ≤ ${numberFormatter(upper)}`;
}
@ -120,24 +157,38 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
const [row] = data;
// @ts-ignore
const { min, max }: { min: number; max: number } = row;
const { groupby, defaultValue, inputRef } = formData;
const { groupby, defaultValue, inputRef, enableSingleValue } = formData;
const enableSingleMinValue = enableSingleValue === SingleValueType.Minimum;
const enableSingleMaxValue = enableSingleValue === SingleValueType.Maximum;
const enableSingleExactValue = enableSingleValue === SingleValueType.Exact;
const rangeValue = enableSingleValue === undefined;
const [col = ''] = ensureIsArray(groupby).map(getColumnLabel);
const [value, setValue] = useState<[number, number]>(
defaultValue ?? [min, max],
defaultValue ?? [min, enableSingleExactValue ? min : max],
);
const [marks, setMarks] = useState<{ [key: number]: string }>({});
const minIndex = 0;
const maxIndex = 1;
const minMax = value ?? [min, max];
const getBounds = useCallback(
(
value: [number, number],
): { lower: number | null; upper: number | null } => {
const [lowerRaw, upperRaw] = value;
if (enableSingleExactValue) {
return { lower: lowerRaw, upper: upperRaw };
}
return {
lower: lowerRaw > min ? lowerRaw : null,
upper: upperRaw < max ? upperRaw : null,
};
},
[max, min],
[max, min, enableSingleExactValue],
);
const handleAfterChange = useCallback(
@ -166,8 +217,34 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
if (row?.min === undefined && row?.max === undefined) {
return;
}
handleAfterChange(filterState.value ?? [min, max]);
}, [JSON.stringify(filterState.value), JSON.stringify(data)]);
let filterStateValue = filterState.value ?? [min, max];
if (enableSingleMaxValue) {
const filterStateMax =
filterStateValue[maxIndex] <= minMax[maxIndex]
? filterStateValue[maxIndex]
: minMax[maxIndex];
filterStateValue = [min, filterStateMax];
} else if (enableSingleMinValue) {
const filterStateMin =
filterStateValue[minIndex] >= minMax[minIndex]
? filterStateValue[minIndex]
: minMax[minIndex];
filterStateValue = [filterStateMin, max];
} else if (enableSingleExactValue) {
filterStateValue = [minMax[minIndex], minMax[minIndex]];
}
handleAfterChange(filterStateValue);
}, [
enableSingleMaxValue,
enableSingleMinValue,
enableSingleExactValue,
JSON.stringify(filterState.value),
JSON.stringify(data),
]);
const formItemExtra = useMemo(() => {
if (filterState.validateMessage) {
@ -180,7 +257,23 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
return undefined;
}, [filterState.validateMessage, filterState.validateStatus]);
const minMax = useMemo(() => value ?? [min, max], [max, min, value]);
useEffect(() => {
if (enableSingleMaxValue) {
handleAfterChange([min, minMax[minIndex]]);
}
}, [enableSingleMaxValue]);
useEffect(() => {
if (enableSingleMinValue) {
handleAfterChange([minMax[maxIndex], max]);
}
}, [enableSingleMinValue]);
useEffect(() => {
if (enableSingleExactValue) {
handleAfterChange([minMax[minIndex], minMax[minIndex]]);
}
}, [enableSingleExactValue]);
return (
<FilterPluginStyle height={height} width={width}>
@ -197,16 +290,53 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
onMouseEnter={setFocusedFilter}
onMouseLeave={unsetFocusedFilter}
>
<Slider
range
min={min}
max={max}
value={minMax}
onAfterChange={handleAfterChange}
onChange={handleChange}
tipFormatter={tipFormatter}
marks={marks}
/>
{enableSingleMaxValue && (
<Slider
min={min}
max={max}
value={minMax[maxIndex]}
tipFormatter={tipFormatter}
marks={marks}
onAfterChange={value => handleAfterChange([min, value])}
onChange={value => handleChange([min, value])}
/>
)}
{enableSingleMinValue && (
<StyledMinSlider
validateStatus={filterState.validateStatus}
min={min}
max={max}
value={minMax[minIndex]}
tipFormatter={tipFormatter}
marks={marks}
onAfterChange={value => handleAfterChange([value, max])}
onChange={value => handleChange([value, max])}
/>
)}
{enableSingleExactValue && (
<Slider
min={min}
max={max}
included={false}
value={minMax[minIndex]}
tipFormatter={tipFormatter}
marks={marks}
onAfterChange={value => handleAfterChange([value, value])}
onChange={value => handleChange([value, value])}
/>
)}
{rangeValue && (
<Slider
range
min={min}
max={max}
value={minMax}
onAfterChange={handleAfterChange}
onChange={handleChange}
tipFormatter={tipFormatter}
marks={marks}
/>
)}
</Wrapper>
</StyledFormItem>
)}

View File

@ -0,0 +1,24 @@
/**
* 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.
*/
export enum SingleValueType {
Minimum,
Exact,
Maximum,
}

View File

@ -22,6 +22,7 @@ import {
sections,
sharedControls,
} from '@superset-ui/chart-controls';
import { SingleValueType } from './SingleValueType';
const config: ControlPanelConfig = {
controlPanelSections: [
@ -57,6 +58,16 @@ const config: ControlPanelConfig = {
description: t('User must select a value for this filter.'),
},
},
{
name: 'enableSingleValue',
config: {
type: 'CheckboxControl',
label: t('Single value'),
default: SingleValueType.Exact,
renderTrigger: true,
description: t('Use only a single value.'),
},
},
],
],
},

View File

@ -54,12 +54,7 @@ describe('Filter utils', () => {
filters: [
{
col: 'testCol',
op: '>=',
val: 0,
},
{
col: 'testCol',
op: '<=',
op: '==',
val: 0,
},
],

View File

@ -60,12 +60,21 @@ export const getRangeExtraFormData = (
upper?: number | null,
) => {
const filters: QueryObjectFilterClause[] = [];
if (lower !== undefined && lower !== null) {
if (lower !== undefined && lower !== null && lower !== upper) {
filters.push({ col, op: '>=', val: lower });
}
if (upper !== undefined && upper !== null) {
if (upper !== undefined && upper !== null && upper !== lower) {
filters.push({ col, op: '<=', val: upper });
}
if (
upper !== undefined &&
upper !== null &&
lower !== undefined &&
lower !== null &&
upper === lower
) {
filters.push({ col, op: '==', val: upper });
}
return filters.length
? {