mirror of
https://github.com/apache/superset.git
synced 2024-09-16 10:39:55 -04:00
* 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:
parent
f949c8ed7a
commit
54b56fe12f
@ -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>
|
||||
)}
|
||||
|
@ -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 } }];
|
||||
|
@ -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 =
|
||||
|
@ -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],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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>
|
||||
)}
|
||||
|
@ -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,
|
||||
}
|
@ -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.'),
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
|
@ -54,12 +54,7 @@ describe('Filter utils', () => {
|
||||
filters: [
|
||||
{
|
||||
col: 'testCol',
|
||||
op: '>=',
|
||||
val: 0,
|
||||
},
|
||||
{
|
||||
col: 'testCol',
|
||||
op: '<=',
|
||||
op: '==',
|
||||
val: 0,
|
||||
},
|
||||
],
|
||||
|
@ -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
|
||||
? {
|
||||
|
Loading…
Reference in New Issue
Block a user