mirror of
https://github.com/apache/superset.git
synced 2026-04-18 07:35:09 +00:00
feat(filter): adding inputs to Numerical Range Filter (#31726)
Co-authored-by: Diego Pucci <diegopucci.me@gmail.com> Co-authored-by: Mehmet Salih Yavuz <salih.yavuz@proton.me>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
/**
|
||||
*
|
||||
* 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
|
||||
@@ -192,23 +193,34 @@ describe('Native filters', () => {
|
||||
testItems.filterNumericalColumn,
|
||||
);
|
||||
saveNativeFilterSettings([]);
|
||||
// assertions
|
||||
cy.get(nativeFilters.slider.slider).should('be.visible').click('center');
|
||||
|
||||
// Assertions
|
||||
cy.get('[data-test="range-filter-from-input"]')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
|
||||
cy.get('[data-test="range-filter-from-input"]').type('{selectall}5');
|
||||
|
||||
cy.get('[data-test="range-filter-to-input"]')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
|
||||
cy.get('[data-test="range-filter-to-input"]').type('{selectall}50');
|
||||
cy.get(nativeFilters.applyFilter).click();
|
||||
// assert that the url contains 'native_filters' in the url
|
||||
|
||||
// Assert that the URL contains 'native_filters'
|
||||
cy.url().then(u => {
|
||||
const ur = new URL(u);
|
||||
expect(ur.search).to.include('native_filters');
|
||||
// assert that the start handle has a value
|
||||
cy.get(nativeFilters.slider.startHandle)
|
||||
.invoke('attr', 'aria-valuenow')
|
||||
.should('exist');
|
||||
// assert that the end handle has a value
|
||||
cy.get(nativeFilters.slider.endHandle)
|
||||
.invoke('attr', 'aria-valuenow')
|
||||
.should('exist');
|
||||
// assert slider text matches what we should have
|
||||
cy.get(nativeFilters.slider.sliderText).should('have.text', '49');
|
||||
|
||||
cy.get('[data-test="range-filter-from-input"]')
|
||||
.invoke('val')
|
||||
.should('equal', '5');
|
||||
|
||||
// Assert that the "To" input has the correct value
|
||||
cy.get('[data-test="range-filter-to-input"]')
|
||||
.invoke('val')
|
||||
.should('equal', '50');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -489,7 +489,7 @@ export function inputNativeFilterDefaultValue(
|
||||
) {
|
||||
if (!multiple) {
|
||||
cy.contains('Filter has default value').click();
|
||||
cy.contains('Default value is required').should('be.visible');
|
||||
cy.contains('Please choose a valid value').should('be.visible');
|
||||
cy.get(nativeFilters.modal.container).within(() => {
|
||||
cy.get(
|
||||
nativeFilters.filterConfigurationSections.filterPlaceholder,
|
||||
|
||||
47
superset-frontend/src/components/Metadata/index.tsx
Normal file
47
superset-frontend/src/components/Metadata/index.tsx
Normal file
@@ -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.
|
||||
*/
|
||||
|
||||
import { styled } from '@superset-ui/core';
|
||||
|
||||
const MetadataWrapper = styled.div`
|
||||
display: flex;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 100%;
|
||||
margin-top: ${({ theme }) => theme.gridUnit}px;
|
||||
`;
|
||||
|
||||
const MetadataText = styled.span`
|
||||
font-size: ${({ theme }) => theme.typography.sizes.xs}px;
|
||||
color: ${({ theme }) => theme.colors.grayscale.light1};
|
||||
font-weight: ${({ theme }) => theme.typography.weights.medium};
|
||||
`;
|
||||
|
||||
export type MetadataProps = {
|
||||
value: string;
|
||||
};
|
||||
|
||||
const Metadata: React.FC<MetadataProps> = ({ value }) => (
|
||||
<MetadataWrapper>
|
||||
<MetadataText>{value}</MetadataText>
|
||||
</MetadataWrapper>
|
||||
);
|
||||
|
||||
export default Metadata;
|
||||
@@ -288,8 +288,8 @@ const FilterValue: FC<FilterControlProps> = ({
|
||||
|
||||
const filterState = useMemo(
|
||||
() => ({
|
||||
...filter.dataMask?.filterState,
|
||||
validateStatus,
|
||||
...filter.dataMask?.filterState,
|
||||
}),
|
||||
[filter.dataMask?.filterState, validateStatus],
|
||||
);
|
||||
|
||||
@@ -42,11 +42,19 @@ export const checkIsMissingRequiredValue = (
|
||||
);
|
||||
};
|
||||
|
||||
export const checkIsValidateError = (dataMask: DataMaskStateWithId) => {
|
||||
const values = Object.values(dataMask);
|
||||
return values.every(value => value.filterState?.validateStatus !== 'error');
|
||||
};
|
||||
|
||||
export const checkIsApplyDisabled = (
|
||||
dataMaskSelected: DataMaskStateWithId,
|
||||
dataMaskApplied: DataMaskStateWithId,
|
||||
filters: Filter[],
|
||||
) => {
|
||||
if (!checkIsValidateError(dataMaskSelected)) {
|
||||
return true;
|
||||
}
|
||||
const dataSelectedValues = Object.values(dataMaskSelected);
|
||||
const dataAppliedValues = Object.values(dataMaskApplied);
|
||||
return (
|
||||
|
||||
@@ -1275,7 +1275,7 @@ const FiltersConfigForm = (
|
||||
return [...prevErroredFilters, filterId];
|
||||
});
|
||||
return Promise.reject(
|
||||
new Error(t('Default value is required')),
|
||||
new Error(t('Please choose a valid value')),
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { AppSection, GenericDataType } from '@superset-ui/core';
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
import { fireEvent, render, screen } from 'spec/helpers/testing-library';
|
||||
import RangeFilterPlugin from './RangeFilterPlugin';
|
||||
import { SingleValueType } from './SingleValueType';
|
||||
import transformProps from './transformProps';
|
||||
@@ -83,14 +83,15 @@ const rangeProps = {
|
||||
|
||||
describe('RangeFilterPlugin', () => {
|
||||
const setDataMask = jest.fn();
|
||||
const getWrapper = (props = {}) =>
|
||||
const getWrapper = (props: any = {}) =>
|
||||
render(
|
||||
// @ts-ignore
|
||||
<RangeFilterPlugin
|
||||
// @ts-ignore
|
||||
{...transformProps({
|
||||
...rangeProps,
|
||||
formData: { ...rangeProps.formData, ...props },
|
||||
...props,
|
||||
formData: { ...rangeProps.formData, ...props.formData },
|
||||
})}
|
||||
setDataMask={setDataMask}
|
||||
/>,
|
||||
@@ -100,11 +101,50 @@ describe('RangeFilterPlugin', () => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render two numerical inputs', () => {
|
||||
getWrapper();
|
||||
|
||||
const inputs = screen.getAllByRole('spinbutton');
|
||||
expect(inputs).toHaveLength(2);
|
||||
|
||||
expect(inputs[0]).toHaveValue('10');
|
||||
expect(inputs[1]).toHaveValue('70');
|
||||
});
|
||||
|
||||
it('should set the data mask to error when the range is incorrect', () => {
|
||||
getWrapper({ filterState: { value: [null, null] } });
|
||||
|
||||
const inputs = screen.getAllByRole('spinbutton');
|
||||
const fromInput = inputs[0];
|
||||
const toInput = inputs[1];
|
||||
|
||||
fireEvent.change(fromInput, { target: { value: 20 } });
|
||||
|
||||
fireEvent.change(toInput, { target: { value: 10 } });
|
||||
|
||||
fireEvent.blur(toInput);
|
||||
|
||||
expect(setDataMask).toHaveBeenCalledWith({
|
||||
extraFormData: {},
|
||||
filterState: {
|
||||
label: '',
|
||||
validateMessage: 'Please provide a valid range',
|
||||
validateStatus: 'error',
|
||||
value: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should call setDataMask with correct filter', () => {
|
||||
getWrapper();
|
||||
expect(setDataMask).toHaveBeenCalledWith({
|
||||
extraFormData: {
|
||||
filters: [
|
||||
{
|
||||
col: 'SP_POP_TOTL',
|
||||
op: '>=',
|
||||
val: 10,
|
||||
},
|
||||
{
|
||||
col: 'SP_POP_TOTL',
|
||||
op: '<=',
|
||||
@@ -113,16 +153,21 @@ describe('RangeFilterPlugin', () => {
|
||||
],
|
||||
},
|
||||
filterState: {
|
||||
label: 'x ≤ 70',
|
||||
label: '10 ≤ x ≤ 70',
|
||||
value: [10, 70],
|
||||
validateMessage: '',
|
||||
validateStatus: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should call setDataMask with correct greater than filter', () => {
|
||||
getWrapper({
|
||||
enableSingleValue: SingleValueType.Minimum,
|
||||
defaultValue: [20, 60],
|
||||
filterState: { value: [20, null] },
|
||||
formData: {
|
||||
enableSingleValue: SingleValueType.Minimum,
|
||||
defaultValue: undefined,
|
||||
},
|
||||
});
|
||||
expect(setDataMask).toHaveBeenCalledWith({
|
||||
extraFormData: {
|
||||
@@ -135,17 +180,22 @@ describe('RangeFilterPlugin', () => {
|
||||
],
|
||||
},
|
||||
filterState: {
|
||||
validateStatus: undefined,
|
||||
validateMessage: '',
|
||||
label: 'x ≥ 20',
|
||||
value: [20, 100],
|
||||
value: [20, null],
|
||||
},
|
||||
});
|
||||
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '20');
|
||||
const input = screen.getByRole('spinbutton');
|
||||
expect(input).toHaveValue('20');
|
||||
});
|
||||
|
||||
it('should call setDataMask with correct less than filter', () => {
|
||||
getWrapper({
|
||||
enableSingleValue: SingleValueType.Maximum,
|
||||
defaultValue: [20, 60],
|
||||
filterState: { value: [null, 60] },
|
||||
formData: {
|
||||
enableSingleValue: SingleValueType.Maximum,
|
||||
},
|
||||
});
|
||||
expect(setDataMask).toHaveBeenCalledWith({
|
||||
extraFormData: {
|
||||
@@ -159,14 +209,22 @@ describe('RangeFilterPlugin', () => {
|
||||
},
|
||||
filterState: {
|
||||
label: 'x ≤ 60',
|
||||
value: [10, 60],
|
||||
value: [null, 60],
|
||||
validateMessage: '',
|
||||
validateStatus: undefined,
|
||||
},
|
||||
});
|
||||
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '60');
|
||||
const input = screen.getByRole('spinbutton');
|
||||
expect(input).toHaveValue('60');
|
||||
});
|
||||
|
||||
it('should call setDataMask with correct exact filter', () => {
|
||||
getWrapper({ enableSingleValue: SingleValueType.Exact });
|
||||
getWrapper({
|
||||
formData: {
|
||||
enableSingleValue: SingleValueType.Exact,
|
||||
},
|
||||
filterState: { value: [10, 10] },
|
||||
});
|
||||
expect(setDataMask).toHaveBeenCalledWith({
|
||||
extraFormData: {
|
||||
filters: [
|
||||
@@ -180,6 +238,8 @@ describe('RangeFilterPlugin', () => {
|
||||
filterState: {
|
||||
label: 'x = 10',
|
||||
value: [10, 10],
|
||||
validateStatus: undefined,
|
||||
validateMessage: '',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,120 +20,53 @@ import {
|
||||
ensureIsArray,
|
||||
getColumnLabel,
|
||||
getNumberFormatter,
|
||||
isEqualArray,
|
||||
NumberFormats,
|
||||
styled,
|
||||
t,
|
||||
} from '@superset-ui/core';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { rgba } from 'emotion-rgba';
|
||||
import { AntdSlider } from 'src/components';
|
||||
import { InputNumber } from 'src/components/Input';
|
||||
import { FilterBarOrientation } from 'src/dashboard/types';
|
||||
import Metadata from 'src/components/Metadata';
|
||||
import { isNumber } from 'lodash';
|
||||
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';
|
||||
type InputValue = number | null;
|
||||
type RangeValue = [InputValue, InputValue];
|
||||
|
||||
const StyledMinSlider = styled(AntdSlider)<{
|
||||
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 StyledDivider = styled.span`
|
||||
margin: 0 ${({ theme }) => theme.gridUnit * 3}px;
|
||||
color: ${({ theme }) => theme.colors.grayscale.light1};
|
||||
font-weight: ${({ theme }) => theme.typography.weights.bold};
|
||||
font-size: ${({ theme }) => theme.typography.sizes.m}px;
|
||||
align-content: center;
|
||||
`;
|
||||
|
||||
const Wrapper = styled.div<{
|
||||
validateStatus?: 'error' | 'warning' | 'info';
|
||||
orientation?: FilterBarOrientation;
|
||||
isOverflowing?: boolean;
|
||||
}>`
|
||||
${({ theme, validateStatus, orientation, isOverflowing }) => `
|
||||
border: 1px solid transparent;
|
||||
&:focus {
|
||||
border: 1px solid
|
||||
${theme.colors[validateStatus || 'primary']?.base};
|
||||
outline: 0;
|
||||
box-shadow: 0 0 0 3px
|
||||
${rgba(theme.colors[validateStatus || 'primary']?.base, 0.2)};
|
||||
}
|
||||
& .ant-slider {
|
||||
margin-top: ${
|
||||
orientation === FilterBarOrientation.Horizontal ? 0 : theme.gridUnit
|
||||
}px;
|
||||
margin-bottom: ${
|
||||
orientation === FilterBarOrientation.Horizontal ? 0 : theme.gridUnit * 5
|
||||
}px;
|
||||
const Wrapper = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
${
|
||||
orientation === FilterBarOrientation.Horizontal &&
|
||||
!isOverflowing &&
|
||||
`line-height: 1.2;`
|
||||
}
|
||||
|
||||
& .ant-slider-track {
|
||||
background-color: ${
|
||||
validateStatus && theme.colors[validateStatus]?.light1
|
||||
};
|
||||
}
|
||||
& .ant-slider-handle {
|
||||
border: ${
|
||||
validateStatus && `2px solid ${theme.colors[validateStatus]?.light1}`
|
||||
};
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 3px
|
||||
${rgba(theme.colors[validateStatus || 'primary']?.base, 0.2)};
|
||||
}
|
||||
}
|
||||
& .ant-slider-mark {
|
||||
font-size: ${theme.typography.sizes.s}px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
& .ant-slider-track {
|
||||
background-color: ${
|
||||
validateStatus && theme.colors[validateStatus]?.base
|
||||
};
|
||||
}
|
||||
& .ant-slider-handle {
|
||||
border: ${
|
||||
validateStatus && `2px solid ${theme.colors[validateStatus]?.base}`
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
`}
|
||||
.antd5-input-number {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
`;
|
||||
|
||||
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) {
|
||||
const getLabel = (
|
||||
lower: number | null,
|
||||
upper: number | null,
|
||||
enableSingleExactValue = false,
|
||||
): string => {
|
||||
if (
|
||||
(enableSingleExactValue && lower !== null) ||
|
||||
(lower !== null && lower === upper)
|
||||
) {
|
||||
return `x = ${numberFormatter(lower)}`;
|
||||
}
|
||||
if (lower !== null && upper !== null) {
|
||||
@@ -148,18 +81,58 @@ const getLabel = (lower: number | null, upper: number | null): string => {
|
||||
return '';
|
||||
};
|
||||
|
||||
const getMarks = (
|
||||
lower: number | null,
|
||||
upper: number | null,
|
||||
): { [key: number]: string } => {
|
||||
const newMarks: { [key: number]: string } = {};
|
||||
if (lower !== null) {
|
||||
newMarks[lower] = numberFormatter(lower);
|
||||
const validateRange = (
|
||||
values: RangeValue,
|
||||
min: number,
|
||||
max: number,
|
||||
enableEmptyFilter: boolean,
|
||||
enableSingleValue?: SingleValueType,
|
||||
): { isValid: boolean; errorMessage: string | null } => {
|
||||
const [inputMin, inputMax] = values;
|
||||
const requiredError = t('Filter value is required');
|
||||
const rangeError = t('Please provide a value within range');
|
||||
if (enableSingleValue !== undefined) {
|
||||
const isSingleMin =
|
||||
enableSingleValue === SingleValueType.Minimum ||
|
||||
enableSingleValue === SingleValueType.Exact;
|
||||
const value = isSingleMin ? inputMin : inputMax;
|
||||
|
||||
if (!value && enableEmptyFilter) {
|
||||
return { isValid: false, errorMessage: requiredError };
|
||||
}
|
||||
|
||||
if (isNumber(value) && (value < min || value > max)) {
|
||||
return { isValid: false, errorMessage: rangeError };
|
||||
}
|
||||
|
||||
return { isValid: true, errorMessage: null };
|
||||
}
|
||||
if (upper !== null) {
|
||||
newMarks[upper] = numberFormatter(upper);
|
||||
|
||||
// Range validation
|
||||
if (enableEmptyFilter && (inputMin === null || inputMax === null)) {
|
||||
return { isValid: false, errorMessage: t('Please provide a valid range') };
|
||||
}
|
||||
return newMarks;
|
||||
|
||||
if (!enableEmptyFilter && (inputMin !== null) !== (inputMax !== null)) {
|
||||
return { isValid: false, errorMessage: t('Please provide a valid range') };
|
||||
}
|
||||
|
||||
if (inputMin !== null && inputMax !== null) {
|
||||
if (inputMin > inputMax) {
|
||||
return {
|
||||
isValid: false,
|
||||
errorMessage: t('Minimum value cannot be higher than maximum value'),
|
||||
};
|
||||
}
|
||||
if (inputMin < min || inputMax > max) {
|
||||
return {
|
||||
isValid: false,
|
||||
errorMessage: t('Your range is not within the dataset range'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { isValid: true, errorMessage: null };
|
||||
};
|
||||
|
||||
export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
|
||||
@@ -176,139 +149,191 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
|
||||
setFilterActive,
|
||||
filterState,
|
||||
inputRef,
|
||||
filterBarOrientation,
|
||||
isOverflowingFilterBar,
|
||||
filterBarOrientation = FilterBarOrientation.Vertical,
|
||||
} = props;
|
||||
const [row] = data;
|
||||
// @ts-ignore
|
||||
const { min, max }: { min: number; max: number } = row;
|
||||
const { groupby, defaultValue, 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, enableSingleExactValue ? min : max],
|
||||
);
|
||||
const [marks, setMarks] = useState<{ [key: number]: string }>({});
|
||||
const { groupby, enableSingleValue, enableEmptyFilter, defaultValue } =
|
||||
formData;
|
||||
const minIndex = 0;
|
||||
const maxIndex = 1;
|
||||
const minMax = value ?? [min, max];
|
||||
const enableSingleExactValue = enableSingleValue === SingleValueType.Exact;
|
||||
const rangeInput = enableSingleValue === undefined;
|
||||
|
||||
const getBounds = useCallback(
|
||||
(
|
||||
value: [number, number],
|
||||
): { lower: number | null; upper: number | null } => {
|
||||
const [lowerRaw, upperRaw] = value;
|
||||
const [col = ''] = ensureIsArray(groupby).map(getColumnLabel);
|
||||
|
||||
if (enableSingleExactValue) {
|
||||
return { lower: lowerRaw, upper: upperRaw };
|
||||
}
|
||||
|
||||
return {
|
||||
lower: lowerRaw > min ? lowerRaw : null,
|
||||
upper: upperRaw < max ? upperRaw : null,
|
||||
};
|
||||
},
|
||||
[max, min, enableSingleExactValue],
|
||||
const [inputValue, setInputValue] = useState<RangeValue>(
|
||||
filterState.value || defaultValue || [null, null],
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleAfterChange = useCallback(
|
||||
(value: [number, number]): void => {
|
||||
setValue(value);
|
||||
const { lower, upper } = getBounds(value);
|
||||
setMarks(getMarks(lower, upper));
|
||||
|
||||
const updateDataMaskError = useCallback(
|
||||
(errorMessage: string | null) => {
|
||||
setDataMask({
|
||||
extraFormData: getRangeExtraFormData(col, lower, upper),
|
||||
extraFormData: {},
|
||||
filterState: {
|
||||
value: lower !== null || upper !== null ? value : null,
|
||||
label: getLabel(lower, upper),
|
||||
value: null,
|
||||
label: '',
|
||||
validateStatus: 'error',
|
||||
validateMessage: errorMessage || '',
|
||||
},
|
||||
});
|
||||
},
|
||||
[col, getBounds, setDataMask],
|
||||
[setDataMask],
|
||||
);
|
||||
|
||||
const handleChange = useCallback((value: [number, number]) => {
|
||||
setValue(value);
|
||||
}, []);
|
||||
const updateDataMaskValue = useCallback(
|
||||
(value: RangeValue) => {
|
||||
const [inputMin, inputMax] = value;
|
||||
setDataMask({
|
||||
extraFormData: getRangeExtraFormData(col, inputMin, inputMax),
|
||||
filterState: {
|
||||
value: enableSingleExactValue
|
||||
? [inputMin, inputMin]
|
||||
: [inputMin, inputMax],
|
||||
label: getLabel(inputMin, inputMax, enableSingleExactValue),
|
||||
validateStatus: undefined,
|
||||
validateMessage: '',
|
||||
},
|
||||
});
|
||||
},
|
||||
[setDataMask],
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
let filterStateValue = filterState.value ?? [min, max];
|
||||
if (enableSingleMaxValue) {
|
||||
const filterStateMax =
|
||||
filterStateValue[maxIndex] <= minMax[maxIndex]
|
||||
? filterStateValue[maxIndex]
|
||||
: minMax[maxIndex];
|
||||
if (
|
||||
filterState.validateStatus === 'error' &&
|
||||
error !== filterState.validateMessage
|
||||
) {
|
||||
setError(filterState.validateMessage);
|
||||
|
||||
filterStateValue = [min, filterStateMax];
|
||||
} else if (enableSingleMinValue) {
|
||||
const filterStateMin =
|
||||
filterStateValue[minIndex] >= minMax[minIndex]
|
||||
? filterStateValue[minIndex]
|
||||
: minMax[minIndex];
|
||||
const [inputMin, inputMax] = inputValue;
|
||||
|
||||
filterStateValue = [filterStateMin, max];
|
||||
} else if (enableSingleExactValue) {
|
||||
filterStateValue = [minMax[minIndex], minMax[minIndex]];
|
||||
const { isValid, errorMessage } = validateRange(
|
||||
inputValue,
|
||||
min,
|
||||
max,
|
||||
enableEmptyFilter,
|
||||
enableSingleValue,
|
||||
);
|
||||
|
||||
const isDefaultError =
|
||||
inputMin === null &&
|
||||
inputMax === null &&
|
||||
filterState.validateStatus === 'error';
|
||||
|
||||
if (!isValid || isDefaultError) {
|
||||
setError(errorMessage);
|
||||
updateDataMaskError(errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
updateDataMaskValue(inputValue);
|
||||
return;
|
||||
}
|
||||
if (filterState.validateStatus === 'error') {
|
||||
setError(filterState.validateMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
handleAfterChange(filterStateValue);
|
||||
}, [
|
||||
enableSingleMaxValue,
|
||||
enableSingleMinValue,
|
||||
enableSingleExactValue,
|
||||
JSON.stringify(filterState.value),
|
||||
JSON.stringify(data),
|
||||
]);
|
||||
// Clear all case
|
||||
if (filterState.value === undefined && !filterState.validateStatus) {
|
||||
setInputValue([null, null]);
|
||||
updateDataMaskValue([null, null]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEqualArray(defaultValue, inputValue)) {
|
||||
updateDataMaskValue(defaultValue);
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter state is pre-set case
|
||||
if (filterState.value && !filterState.validateStatus) {
|
||||
setInputValue(filterState.value);
|
||||
updateDataMaskValue(filterState.value);
|
||||
}
|
||||
}, [JSON.stringify(filterState.value)]);
|
||||
|
||||
const metadataText = useMemo(() => {
|
||||
switch (enableSingleValue) {
|
||||
case SingleValueType.Minimum:
|
||||
return t('Filters for values greater than or equal.');
|
||||
case SingleValueType.Maximum:
|
||||
return t('Filters for values less than or equal.');
|
||||
case SingleValueType.Exact:
|
||||
return t('Filters for values equal to this exact value.');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}, [enableSingleValue]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(newValue: number | null, index: 0 | 1) => {
|
||||
if (row?.min === undefined && row?.max === undefined) {
|
||||
return;
|
||||
}
|
||||
const newInputValue: [number | null, number | null] =
|
||||
index === minIndex
|
||||
? [newValue, inputValue[maxIndex]]
|
||||
: [inputValue[minIndex], newValue];
|
||||
|
||||
setInputValue(newInputValue);
|
||||
|
||||
const { isValid, errorMessage } = validateRange(
|
||||
newInputValue,
|
||||
min,
|
||||
max,
|
||||
enableEmptyFilter,
|
||||
enableSingleValue,
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
setError(errorMessage);
|
||||
updateDataMaskError(errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
updateDataMaskValue(newInputValue);
|
||||
},
|
||||
[col, min, max, enableEmptyFilter, enableSingleValue, setDataMask],
|
||||
);
|
||||
|
||||
const formItemExtra = useMemo(() => {
|
||||
if (filterState.validateMessage) {
|
||||
return (
|
||||
<StatusMessage status={filterState.validateStatus}>
|
||||
{filterState.validateMessage}
|
||||
</StatusMessage>
|
||||
);
|
||||
if (error) {
|
||||
return <StatusMessage status="error">{error}</StatusMessage>;
|
||||
}
|
||||
return undefined;
|
||||
}, [filterState.validateMessage, filterState.validateStatus]);
|
||||
}, [error]);
|
||||
|
||||
useEffect(() => {
|
||||
if (enableSingleMaxValue) {
|
||||
handleAfterChange([min, minMax[maxIndex]]);
|
||||
switch (enableSingleValue) {
|
||||
case SingleValueType.Minimum:
|
||||
case SingleValueType.Exact:
|
||||
handleChange(null, maxIndex);
|
||||
break;
|
||||
case SingleValueType.Maximum:
|
||||
handleChange(null, minIndex);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}, [enableSingleMaxValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (enableSingleMinValue) {
|
||||
handleAfterChange([minMax[minIndex], max]);
|
||||
}
|
||||
}, [enableSingleMinValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (enableSingleExactValue) {
|
||||
handleAfterChange([minMax[minIndex], minMax[minIndex]]);
|
||||
}
|
||||
}, [enableSingleExactValue]);
|
||||
|
||||
const MIN_NUM_STEPS = 20;
|
||||
const stepHeuristic = (min: number, max: number) => {
|
||||
const maxStepSize = (max - min) / MIN_NUM_STEPS;
|
||||
// normalizedStepSize: .06 -> .01, .003 -> .001
|
||||
const normalizedStepSize = `1E${Math.floor(Math.log10(maxStepSize))}`;
|
||||
return Math.min(1, parseFloat(normalizedStepSize));
|
||||
};
|
||||
|
||||
const step = max - min <= 1 ? stepHeuristic(min, max) : 1;
|
||||
setDataMask({
|
||||
extraFormData: {},
|
||||
filterState: {
|
||||
value: null,
|
||||
label: '',
|
||||
},
|
||||
});
|
||||
}, [enableSingleValue]);
|
||||
|
||||
return (
|
||||
<FilterPluginStyle height={height} width={width}>
|
||||
@@ -322,9 +347,6 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
|
||||
<Wrapper
|
||||
tabIndex={-1}
|
||||
ref={inputRef}
|
||||
validateStatus={filterState.validateStatus}
|
||||
orientation={filterBarOrientation}
|
||||
isOverflowing={isOverflowingFilterBar}
|
||||
onFocus={setFocusedFilter}
|
||||
onBlur={unsetFocusedFilter}
|
||||
onMouseEnter={setHoveredFilter}
|
||||
@@ -332,58 +354,34 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
|
||||
onMouseDown={() => setFilterActive(true)}
|
||||
onMouseUp={() => setFilterActive(false)}
|
||||
>
|
||||
{enableSingleMaxValue && (
|
||||
<AntdSlider
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={minMax[maxIndex]}
|
||||
tipFormatter={tipFormatter}
|
||||
marks={marks}
|
||||
onAfterChange={value => handleAfterChange([min, value])}
|
||||
onChange={value => handleChange([min, value])}
|
||||
{(enableSingleValue === SingleValueType.Minimum ||
|
||||
enableSingleValue === SingleValueType.Exact ||
|
||||
enableSingleValue === undefined) && (
|
||||
<InputNumber
|
||||
value={inputValue[minIndex]}
|
||||
onChange={val => handleChange(val, minIndex)}
|
||||
placeholder={`${min}`}
|
||||
status={filterState.validateStatus}
|
||||
data-test="range-filter-from-input"
|
||||
/>
|
||||
)}
|
||||
{enableSingleMinValue && (
|
||||
<StyledMinSlider
|
||||
validateStatus={filterState.validateStatus}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={minMax[minIndex]}
|
||||
tipFormatter={tipFormatter}
|
||||
marks={marks}
|
||||
onAfterChange={value => handleAfterChange([value, max])}
|
||||
onChange={value => handleChange([value, max])}
|
||||
/>
|
||||
{enableSingleValue === undefined && (
|
||||
<StyledDivider>-</StyledDivider>
|
||||
)}
|
||||
{enableSingleExactValue && (
|
||||
<AntdSlider
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
included={false}
|
||||
value={minMax[minIndex]}
|
||||
tipFormatter={tipFormatter}
|
||||
marks={marks}
|
||||
onAfterChange={value => handleAfterChange([value, value])}
|
||||
onChange={value => handleChange([value, value])}
|
||||
/>
|
||||
)}
|
||||
{rangeValue && (
|
||||
<AntdSlider
|
||||
range
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={minMax}
|
||||
onAfterChange={handleAfterChange}
|
||||
onChange={handleChange}
|
||||
tipFormatter={tipFormatter}
|
||||
marks={marks}
|
||||
{(enableSingleValue === SingleValueType.Maximum ||
|
||||
enableSingleValue === undefined) && (
|
||||
<InputNumber
|
||||
value={inputValue[maxIndex]}
|
||||
onChange={val => handleChange(val, maxIndex)}
|
||||
placeholder={`${max}`}
|
||||
data-test="range-filter-to-input"
|
||||
status={filterState.validateStatus}
|
||||
/>
|
||||
)}
|
||||
</Wrapper>
|
||||
{(rangeInput ||
|
||||
filterBarOrientation === FilterBarOrientation.Vertical) &&
|
||||
!filterState.validateStatus && <Metadata value={metadataText} />}
|
||||
</StyledFormItem>
|
||||
)}
|
||||
</FilterPluginStyle>
|
||||
|
||||
Reference in New Issue
Block a user