feat(filter): Add Slider Range Inputs Option for Numerical Range Filters (#33170)

Co-authored-by: Geido <60598000+geido@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Mehmet Salih Yavuz <salih.yavuz@proton.me>
This commit is contained in:
Pius Iniobong
2025-07-04 11:31:23 +01:00
committed by GitHub
parent 4718767ddb
commit ef14a5fbb4
11 changed files with 927 additions and 238 deletions

View File

@@ -174,46 +174,139 @@ describe('Native filters', () => {
validateFilterContentOnDashboard(testItems.topTenChart.filterColumnYear);
});
it('User can create a numerical range filter', () => {
visitDashboard();
enterNativeFilterEditModal(false);
fillNativeFilterForm(
testItems.filterType.numerical,
testItems.filterNumericalColumn,
testItems.datasetForNativeFilter,
testItems.filterNumericalColumn,
);
saveNativeFilterSettings([]);
// Assertions
cy.get('[data-test="range-filter-from-input"]')
.should('be.visible')
.click();
cy.get('[data-test="range-filter-from-input"]').type('{selectall}40');
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({
force: true,
describe.only('Numerical Range Filter - Display Modes', () => {
beforeEach(() => {
visitDashboard();
});
// Assert that the URL contains 'native_filters'
cy.url().then(u => {
const ur = new URL(u);
expect(ur.search).to.include('native_filters');
const expandFilterConfiguration = () => {
cy.get('.ant-collapse-header')
.contains('Filter Configuration')
.should('be.visible')
.then($header => {
cy.wrap($header)
.closest('.ant-collapse-item')
.invoke('hasClass', 'ant-collapse-item-active')
.then(isExpanded => {
if (!isExpanded) cy.wrap($header).click();
});
});
cy.get('.ant-collapse-content-box').should('be.visible');
};
const selectRangeTypeOption = (label: string) => {
cy.contains('Range Type')
.should('be.visible')
.closest('.ant-form-item')
.within(() => {
cy.get('.ant-select-selector').click();
});
cy.get('.ant-select-dropdown:visible')
.contains('.ant-select-item-option', label)
.click();
};
const applyAndAssertInputs = (from: string, to: string) => {
// Set 'from' input
cy.get('[data-test="range-filter-from-input"]').clear();
cy.get('[data-test="range-filter-from-input"]').type(from);
cy.get('[data-test="range-filter-from-input"]').blur();
// Set 'to' input
cy.get('[data-test="range-filter-to-input"]').clear();
cy.get('[data-test="range-filter-to-input"]').type(to);
cy.get('[data-test="range-filter-to-input"]').blur();
// Assert values without chaining after .invoke()
cy.get('[data-test="range-filter-from-input"]')
.invoke('val')
.should('equal', '40');
.then(val => {
expect(val).to.equal(from);
});
// Assert that the "To" input has the correct value
cy.get('[data-test="range-filter-to-input"]')
.invoke('val')
.should('equal', '50');
.then(val => {
expect(val).to.equal(to);
});
};
it('User can create a numerical range filter with "Range Inputs" display mode', () => {
enterNativeFilterEditModal(false);
fillNativeFilterForm(
testItems.filterType.numerical,
testItems.filterNumericalColumn,
testItems.datasetForNativeFilter,
testItems.filterNumericalColumn,
);
expandFilterConfiguration();
selectRangeTypeOption('Range Inputs');
saveNativeFilterSettings([]);
cy.wait(500); // allow filter to mount
applyAndAssertInputs('40', '70');
});
it('User can change the display mode to "Slider"', () => {
enterNativeFilterEditModal(false);
fillNativeFilterForm(
testItems.filterType.numerical,
testItems.filterNumericalColumn,
testItems.datasetForNativeFilter,
testItems.filterNumericalColumn,
);
expandFilterConfiguration();
cy.contains('Range Type')
.should('be.visible')
.closest('.ant-form-item')
.within(() => {
cy.get('.ant-select-selector').click({ force: true });
});
cy.get('.ant-select-dropdown:visible .ant-select-item-option')
.contains(/^Slider$/)
.click({ force: true });
cy.get('.ant-select-selector').should('contain.text', 'Slider');
saveNativeFilterSettings([]);
cy.get('.ant-slider', { timeout: 10000 }).should('be.visible');
cy.get('[data-test="range-filter-from-input"]', {
timeout: 5000,
}).should('not.exist');
cy.get('[data-test="range-filter-to-input"]', { timeout: 5000 }).should(
'not.exist',
);
});
it('User can change the display mode to "Slider and range input"', () => {
enterNativeFilterEditModal(false);
// Re-create filter
fillNativeFilterForm(
testItems.filterType.numerical,
testItems.filterNumericalColumn,
testItems.datasetForNativeFilter,
testItems.filterNumericalColumn,
);
expandFilterConfiguration();
selectRangeTypeOption('Slider and range input');
saveNativeFilterSettings([]);
cy.wait(500);
applyAndAssertInputs('40', '70');
});
});

View File

@@ -363,9 +363,26 @@ export function saveNativeFilterSettings(charts: ChartSpec[]) {
cy.get(nativeFilters.modal.footer)
.contains('Save')
.should('be.visible')
.click();
.click({ force: true });
// Wait for modal to either close or remain open
cy.get('body').should($body => {
const modalExists = $body.find(nativeFilters.modal.container).length > 0;
if (modalExists) {
cy.get(nativeFilters.modal.footer)
.contains('Save')
.should('be.visible')
.click({ force: true });
}
});
// Ensure modal is closed
cy.get(nativeFilters.modal.container).should('not.exist');
charts.forEach(waitForChartLoad);
// Wait for all charts to load
charts.forEach(chart => {
waitForChartLoad(chart);
});
}
/** ************************************************************************

View File

@@ -17,8 +17,10 @@
* under the License.
*/
import {
CSSProperties,
cloneElement,
forwardRef,
ReactElement,
RefObject,
useEffect,
useImperativeHandle,
@@ -26,17 +28,85 @@ import {
useMemo,
useState,
useRef,
ReactNode,
useCallback,
} from 'react';
import { Global } from '@emotion/react';
import { css, t, useTheme, usePrevious } from '@superset-ui/core';
import { useResizeDetector } from 'react-resize-detector';
import { Badge, Icons, Button, Tooltip, Popover } from '..';
import type {
DropdownContainerProps,
DropdownItem,
DropdownRef,
} from './types';
/**
* Container item.
*/
export interface DropdownItem {
/**
* String that uniquely identifies the item.
*/
id: string;
/**
* The element to be rendered.
*/
element: ReactElement;
}
/**
* Horizontal container that displays overflowed items in a dropdown.
* It shows an indicator of how many items are currently overflowing.
*/
export interface DropdownContainerProps {
/**
* Array of items. The id property is used to uniquely identify
* the elements when rendering or dealing with event handlers.
*/
items: DropdownItem[];
/**
* Event handler called every time an element moves between
* main container and dropdown.
*/
onOverflowingStateChange?: (overflowingState: {
notOverflowed: string[];
overflowed: string[];
}) => void;
/**
* Option to customize the content of the dropdown.
*/
dropdownContent?: (overflowedItems: DropdownItem[]) => ReactElement;
/**
* Dropdown ref.
*/
dropdownRef?: RefObject<HTMLDivElement>;
/**
* Dropdown additional style properties.
*/
dropdownStyle?: CSSProperties;
/**
* Displayed count in the dropdown trigger.
*/
dropdownTriggerCount?: number;
/**
* Icon of the dropdown trigger.
*/
dropdownTriggerIcon?: ReactElement;
/**
* Text of the dropdown trigger.
*/
dropdownTriggerText?: string;
/**
* Text of the dropdown trigger tooltip
*/
dropdownTriggerTooltip?: ReactNode | null;
/**
* Main container additional style properties.
*/
style?: CSSProperties;
/**
* Force render popover content before it's first opened
*/
forceRender?: boolean;
}
export type DropdownRef = HTMLDivElement & { open: () => void };
const MAX_HEIGHT = 500;
@@ -73,6 +143,37 @@ export const DropdownContainer = forwardRef(
const [showOverflow, setShowOverflow] = useState(false);
// callback to update item widths so that the useLayoutEffect runs whenever
// width of any of the child changes
const recalculateItemWidths = useCallback(() => {
const mainItemsContainerNode = current?.children.item(0);
if (mainItemsContainerNode) {
const visibleChildrenElements = Array.from(
mainItemsContainerNode.children,
);
setItemsWidth(prevGlobalWidths => {
if (prevGlobalWidths.length !== items.length) {
return prevGlobalWidths;
}
const newGlobalWidths = [...prevGlobalWidths];
let changed = false;
visibleChildrenElements.forEach((child, indexInVisible) => {
const originalItemIndex = indexInVisible;
if (originalItemIndex < newGlobalWidths.length) {
const newWidth = child.getBoundingClientRect().width;
if (newGlobalWidths[originalItemIndex] !== newWidth) {
newGlobalWidths[originalItemIndex] = newWidth;
changed = true;
}
}
});
return changed ? newGlobalWidths : prevGlobalWidths;
});
}
}, [current?.children, items.length]);
const reduceItems = (items: DropdownItem[]): [DropdownItem[], string[]] =>
items.reduce(
([items, ids], item) => {
@@ -122,24 +223,7 @@ export const DropdownContainer = forwardRef(
childrenArray.map(child => resizeObserver.unobserve(child));
resizeObserver.disconnect();
};
}, [items.length]);
// callback to update item widths so that the useLayoutEffect runs whenever
// width of any of the child changes
const recalculateItemWidths = () => {
const container = current?.children.item(0);
if (container) {
const { children } = container;
const childrenArray = Array.from(children);
const currentWidths = childrenArray.map(
child => child.getBoundingClientRect().width,
);
// Update state with new widths
setItemsWidth(currentWidths);
}
};
}, [items.length, current, recalculateItemWidths]);
useLayoutEffect(() => {
if (popoverVisible) {
@@ -206,6 +290,7 @@ export const DropdownContainer = forwardRef(
overflowedItems.length,
previousWidth,
width,
popoverVisible,
]);
useEffect(() => {
@@ -376,5 +461,3 @@ export const DropdownContainer = forwardRef(
);
},
);
export type { DropdownItem, DropdownRef };

View File

@@ -162,6 +162,8 @@ const HorizontalFormItem = styled(StyledFormItem)<{
}
.ant-form-item-label {
display: flex;
align-items: center;
overflow: visible;
padding-bottom: 0;
margin-right: ${({ theme }) => theme.sizeUnit * 2}px;
@@ -177,7 +179,7 @@ const HorizontalFormItem = styled(StyledFormItem)<{
}
.ant-form-item-control {
width: ${({ inverseSelection }) => (inverseSelection ? 252 : 164)}px;
min-width: ${({ inverseSelection }) => (inverseSelection ? 252 : 164)}px;
}
.select-container {

View File

@@ -82,6 +82,7 @@ import DateFilterControl from 'src/explore/components/controls/DateFilterControl
import AdhocFilterControl from 'src/explore/components/controls/FilterControl/AdhocFilterControl';
import { waitForAsyncData } from 'src/middleware/asyncEvent';
import { SingleValueType } from 'src/filters/components/Range/SingleValueType';
import { RangeDisplayMode } from 'src/filters/components/Range/types';
import {
getFormData,
mergeExtraFormData,
@@ -736,6 +737,12 @@ const FiltersConfigForm = (
/>
</StyledRowFormItem>
);
const isValidFilterValue = (value: any, isRangeFilter: boolean) => {
if (isRangeFilter) {
return Array.isArray(value) && (value[0] !== null || value[1] !== null);
}
return !!value;
};
return (
<Tabs
activeKey={activeTabKey}
@@ -1168,20 +1175,69 @@ const FiltersConfigForm = (
</CollapsibleControl>
</FormItem>
) : (
<FormItem
name={['filters', filterId, 'rangeFilter']}
>
<CollapsibleControl
initialValue={hasEnableSingleValue}
title={t('Single Value')}
onChange={checked => {
onEnableSingleValueChanged(
checked
? SingleValueType.Exact
: undefined,
);
formChanged();
}}
<>
<FormItem
name={[
'filters',
filterId,
'rangeFilter',
]}
>
<CollapsibleControl
initialValue={hasEnableSingleValue}
title={t('Single Value')}
onChange={checked => {
onEnableSingleValueChanged(
checked
? SingleValueType.Exact
: undefined,
);
formChanged();
}}
>
<StyledRowFormItem
expanded={expanded}
name={[
'filters',
filterId,
'controlValues',
'enableSingleValue',
]}
initialValue={enableSingleValue}
label={
<StyledLabel>
{t('Single value type')}
</StyledLabel>
}
>
<Radio.GroupWrapper
onChange={value => {
onEnableSingleValueChanged(
value.target.value,
);
formChanged();
}}
options={[
{
label: t('Minimum'),
value: SingleValueType.Minimum,
},
{
label: t('Exact'),
value: SingleValueType.Exact,
},
{
label: t('Maximum'),
value: SingleValueType.Maximum,
},
]}
/>
</StyledRowFormItem>
</CollapsibleControl>
</FormItem>
<FormItem
name={['filters', filterId, 'rangeType']}
>
<StyledRowFormItem
expanded={expanded}
@@ -1189,40 +1245,66 @@ const FiltersConfigForm = (
'filters',
filterId,
'controlValues',
'enableSingleValue',
'rangeDisplayMode',
]}
initialValue={enableSingleValue}
initialValue={
formFilter?.controlValues
?.rangeDisplayMode ||
filterToEdit?.controlValues
?.rangeDisplayMode ||
RangeDisplayMode.SliderAndInput
}
label={
<StyledLabel>
{t('Single value type')}
{t('Range Type')}
</StyledLabel>
}
>
<Radio.GroupWrapper
onChange={value => {
onEnableSingleValueChanged(
value.target.value,
);
formChanged();
}}
<Select
ariaLabel={t('Range Type')}
options={[
{
label: t('Minimum'),
value: SingleValueType.Minimum,
value: RangeDisplayMode.Slider,
label: t('Slider'),
},
{
label: t('Exact'),
value: SingleValueType.Exact,
value: RangeDisplayMode.Input,
label: t('Range Inputs'),
},
{
label: t('Maximum'),
value: SingleValueType.Maximum,
value:
RangeDisplayMode.SliderAndInput,
label: t(
'Slider and range input',
),
},
]}
onChange={value => {
const previous =
form.getFieldValue('filters')?.[
filterId
].controlValues || {};
const rangeDisplayMode =
value ||
RangeDisplayMode.SliderAndInput;
setNativeFilterFieldValues(
form,
filterId,
{
controlValues: {
...previous,
rangeDisplayMode,
},
},
);
forceUpdate();
formChanged();
}}
/>
</StyledRowFormItem>
</CollapsibleControl>
</FormItem>
</FormItem>
</>
)}
</>
),
@@ -1269,6 +1351,25 @@ const FiltersConfigForm = (
setNativeFilterFieldValues(form, filterId, {
defaultDataMask: null,
});
} else {
// When the checkbox is checked, explicitly set an empty default data mask
// for range filters to trigger validation
if (
formFilter?.filterType === 'filter_range'
) {
setNativeFilterFieldValues(form, filterId, {
defaultDataMask: {
extraFormData: {},
filterState: {
value: [null, null],
},
},
});
}
// Validate immediately when the checkbox is checked
form.validateFields([
['filters', filterId, 'defaultDataMask'],
]);
}
formChanged();
}}
@@ -1292,12 +1393,22 @@ const FiltersConfigForm = (
rules={[
{
validator: () => {
if (
// For range filters, check if at least one of the values in the array is non-null
const value =
formFilter?.defaultDataMask
?.filterState?.value
) {
// requires managing the error as the DefaultValue
// component does not use an Antdesign compatible input
?.filterState?.value;
const isRangeFilter =
formFilter?.filterType ===
'filter_range';
// Check if value exists and is valid
const hasValidValue =
isValidFilterValue(
value,
isRangeFilter,
);
if (hasValidValue) {
const formValidationFields =
form.getFieldsError();
setErroredFilters(
@@ -1315,6 +1426,7 @@ const FiltersConfigForm = (
);
return Promise.resolve();
}
setErroredFilters(
prevErroredFilters => {
if (

View File

@@ -342,6 +342,7 @@ const cachedNativeFilterDataForChart: Record<
rejectedColumns: Set<string>;
}
> = {};
export const selectNativeIndicatorsForChart = (
nativeFilters: Filters,
dataMask: DataMaskStateWithId,
@@ -355,17 +356,16 @@ export const selectNativeIndicatorsForChart = (
const cachedFilterData = cachedNativeFilterDataForChart[chartId];
if (
cachedNativeIndicatorsForChart[chartId] &&
areObjectsEqual(cachedFilterData?.appliedColumns, appliedColumns) &&
areObjectsEqual(cachedFilterData?.rejectedColumns, rejectedColumns) &&
cachedFilterData?.nativeFilters === nativeFilters &&
cachedFilterData?.chartLayoutItems === chartLayoutItems &&
cachedFilterData?.chartConfiguration === chartConfiguration &&
cachedFilterData?.dataMask === dataMask
cachedNativeIndicatorsForChart[chartId]?.length &&
areObjectsEqual(cachedFilterData.appliedColumns, appliedColumns) &&
areObjectsEqual(cachedFilterData.rejectedColumns, rejectedColumns) &&
areObjectsEqual(cachedFilterData.nativeFilters, nativeFilters) &&
areObjectsEqual(cachedFilterData.chartLayoutItems, chartLayoutItems) &&
areObjectsEqual(cachedFilterData.chartConfiguration, chartConfiguration) &&
areObjectsEqual(cachedFilterData.dataMask, dataMask)
) {
return cachedNativeIndicatorsForChart[chartId];
}
const nativeFilterIndicators =
nativeFilters &&
Object.values(nativeFilters)

View File

@@ -17,8 +17,10 @@
* under the License.
*/
import { AppSection, GenericDataType } from '@superset-ui/core';
import { fireEvent, render, screen } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import { render, screen } from 'spec/helpers/testing-library';
import RangeFilterPlugin from './RangeFilterPlugin';
import { RangeDisplayMode } from './types';
import { SingleValueType } from './SingleValueType';
import transformProps from './transformProps';
@@ -101,7 +103,7 @@ describe('RangeFilterPlugin', () => {
jest.clearAllMocks();
});
it('should render two numerical inputs', () => {
it('should render two numerical inputs and a slider by default', () => {
getWrapper();
const inputs = screen.getAllByRole('spinbutton');
@@ -109,26 +111,32 @@ describe('RangeFilterPlugin', () => {
expect(inputs[0]).toHaveValue('10');
expect(inputs[1]).toHaveValue('70');
// For a range slider, there are two slider handles
const sliders = screen.getAllByRole('slider');
expect(sliders.length).toBeGreaterThan(0);
});
it('should set the data mask to error when the range is incorrect', () => {
it('should set the data mask to error when the range is incorrect', async () => {
getWrapper({ filterState: { value: [null, null] } });
const inputs = screen.getAllByRole('spinbutton');
const fromInput = inputs[0];
const toInput = inputs[1];
fireEvent.change(fromInput, { target: { value: 20 } });
userEvent.clear(fromInput);
userEvent.type(fromInput, '20');
fireEvent.change(toInput, { target: { value: 10 } });
userEvent.clear(toInput);
userEvent.type(toInput, '10');
fireEvent.blur(toInput);
userEvent.tab();
expect(setDataMask).toHaveBeenCalledWith({
extraFormData: {},
filterState: {
label: '',
validateMessage: 'Please provide a valid range',
validateMessage: 'Numbers must be within 10 and 100',
validateStatus: 'error',
value: null,
},
@@ -186,8 +194,10 @@ describe('RangeFilterPlugin', () => {
value: [20, null],
},
});
const input = screen.getByRole('spinbutton');
expect(input).toHaveValue('20');
const inputs = screen.getAllByRole('spinbutton');
expect(inputs).toHaveLength(1);
expect(inputs[0]).toHaveValue('20');
});
it('should call setDataMask with correct less than filter', () => {
@@ -214,8 +224,10 @@ describe('RangeFilterPlugin', () => {
validateStatus: undefined,
},
});
const input = screen.getByRole('spinbutton');
expect(input).toHaveValue('60');
const inputs = screen.getAllByRole('spinbutton');
expect(inputs).toHaveLength(1);
expect(inputs[0]).toHaveValue('60');
});
it('should call setDataMask with correct exact filter', () => {
@@ -242,5 +254,69 @@ describe('RangeFilterPlugin', () => {
validateMessage: '',
},
});
const inputs = screen.getAllByRole('spinbutton');
expect(inputs).toHaveLength(1);
expect(inputs[0]).toHaveValue('10');
});
describe('Range Display Modes', () => {
it('should render only the slider in slider mode', () => {
getWrapper({
formData: {
rangeDisplayMode: RangeDisplayMode.Slider,
},
});
const sliders = screen.getAllByRole('slider');
expect(sliders.length).toBeGreaterThan(0);
expect(screen.queryAllByRole('spinbutton')).toHaveLength(0);
});
it('should render only inputs in input mode', () => {
getWrapper({
formData: {
rangeDisplayMode: RangeDisplayMode.Input,
},
});
const inputs = screen.getAllByRole('spinbutton');
expect(inputs).toHaveLength(2);
expect(screen.queryAllByRole('slider')).toHaveLength(0);
});
it('should render both slider and inputs in slider-and-input mode', () => {
getWrapper({
formData: {
rangeDisplayMode: RangeDisplayMode.SliderAndInput,
},
});
// Should show inputs
const inputs = screen.getAllByRole('spinbutton');
expect(inputs).toHaveLength(2);
// Should show slider
const sliders = screen.getAllByRole('slider');
expect(sliders.length).toBeGreaterThan(0);
});
it('should default to slider-and-input mode when not specified', () => {
getWrapper({
formData: {
// No rangeDisplayMode specified
},
});
// Should show inputs
const inputs = screen.getAllByRole('spinbutton');
expect(inputs).toHaveLength(2);
// Should show slider
const sliders = screen.getAllByRole('slider');
expect(sliders.length).toBeGreaterThan(0);
});
});
});

View File

@@ -23,14 +23,17 @@ import {
isEqualArray,
NumberFormats,
styled,
useTheme,
t,
} from '@superset-ui/core';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import { FilterBarOrientation } from 'src/dashboard/types';
import Metadata from '@superset-ui/core/components/Metadata';
// import Metadata from '@superset-ui/core/components/Metadata';
import { isNumber } from 'lodash';
import { FormItem, InputNumber } from '@superset-ui/core/components';
import { PluginFilterRangeProps } from './types';
import { InputNumber } from '@superset-ui/core/components/Input';
import Slider from '@superset-ui/core/components/Slider';
import { FormItem, Tooltip, Icons } from '@superset-ui/core/components';
import { PluginFilterRangeProps, RangeDisplayMode } from './types';
import { StatusMessage, FilterPluginStyle } from '../common';
import { getRangeExtraFormData } from '../../utils';
import { SingleValueType } from './SingleValueType';
@@ -49,13 +52,75 @@ const StyledDivider = styled.span`
const Wrapper = styled.div`
display: flex;
justify-content: space-between;
width: 100%;
.ant-input-number {
width: 100%;
.antd5-input-number {
min-width: 80px;
position: relative;
}
`;
const SliderWrapper = styled.div`
${({ theme }) => `
margin: ${theme.sizeUnit * 4}px 0;
padding: 0 ${theme.sizeUnit}px;
`}
`;
const TooltipContainer = styled.div`
${({ theme }) => `
position: absolute;
top: -${theme.sizeUnit * 10}px;
right: 0px;
z-index: 100;
display: flex;
align-items: center;
.tooltip-icon {
margin-left: ${theme.sizeUnit * 2}px;
}
`}
`;
const HorizontalLayout = styled.div`
${({ theme }) => `
display: flex;
flex-direction: column;
gap: ${theme.sizeUnit * 4}px;
width: 100%;
.controls-container {
display: flex;
align-items: center;
gap: ${theme.sizeUnit * 4}px;
width: 100%;
.slider-wrapper {
display: flex;
align-items: center;
flex: 2;
}
.slider-container {
flex: 1;
min-width: 180px;
}
.inputs-container {
min-width: 160px;
max-width: 200px;
}
}
.message-container {
width: 100%;
text-align: center;
padding-top: ${theme.sizeUnit * 2}px;
}
`}
`;
const numberFormatter = getNumberFormatter(NumberFormats.SMART_NUMBER);
const getLabel = (
@@ -89,15 +154,21 @@ const validateRange = (
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');
const requiredError = t('Please provide a valid min or max value');
const minMaxError = t('Min value cannot be greater than max value');
const rangeError = t('Numbers must be within %(min)s and %(max)s', {
min,
max,
});
// Single value validation
if (enableSingleValue !== undefined) {
const isSingleMin =
enableSingleValue === SingleValueType.Minimum ||
enableSingleValue === SingleValueType.Exact;
const value = isSingleMin ? inputMin : inputMax;
if (!value && enableEmptyFilter) {
if (!isNumber(value)) {
return { isValid: false, errorMessage: requiredError };
}
@@ -109,33 +180,43 @@ const validateRange = (
}
// Range validation
if (enableEmptyFilter && (inputMin === null || inputMax === null)) {
return { isValid: false, errorMessage: t('Please provide a valid range') };
// Allow empty ranges if enableEmptyFilter is false
if (!enableEmptyFilter && inputMin === null && inputMax === null) {
return { isValid: true, errorMessage: null };
}
if (!enableEmptyFilter && (inputMin !== null) !== (inputMax !== null)) {
return { isValid: false, errorMessage: t('Please provide a valid range') };
// If enableEmptyFilter is true, at least one value is required
if (enableEmptyFilter && inputMin === null && inputMax === null) {
return {
isValid: false,
errorMessage: requiredError,
};
}
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'),
};
}
// Check relationship between min and max when both are provided
if (inputMin !== null && inputMax !== null && inputMin > inputMax) {
return {
isValid: false,
errorMessage: minMaxError,
};
}
// Check individual value bounds if provided
if (
(inputMin !== null && inputMin < min) ||
(inputMax !== null && inputMax > max)
) {
return {
isValid: false,
errorMessage: rangeError,
};
}
return { isValid: true, errorMessage: null };
};
export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
const theme = useTheme();
const {
data,
formData,
@@ -150,16 +231,23 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
filterState,
inputRef,
filterBarOrientation = FilterBarOrientation.Vertical,
isOverflowingFilterBar,
} = props;
const [row] = data;
// @ts-ignore
const { min, max }: { min: number; max: number } = row;
const { groupby, enableSingleValue, enableEmptyFilter, defaultValue } =
formData;
// Get the display mode from formData
const rangeDisplayMode =
formData?.rangeDisplayMode || RangeDisplayMode.SliderAndInput;
const minIndex = 0;
const maxIndex = 1;
const enableSingleExactValue = enableSingleValue === SingleValueType.Exact;
const rangeInput = enableSingleValue === undefined;
const [col = ''] = ensureIsArray(groupby).map(getColumnLabel);
@@ -168,6 +256,30 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
);
const [error, setError] = useState<string | null>(null);
// Prepare slider value from input value, converting nulls to min/max
const sliderValue = useMemo(() => {
const [inputMin, inputMax] = inputValue;
// For single value filters
if (
enableSingleValue === SingleValueType.Minimum ||
enableSingleValue === SingleValueType.Exact
) {
return inputMin !== null ? inputMin : min;
}
if (enableSingleValue === SingleValueType.Maximum) {
return inputMax !== null ? inputMax : max;
}
// For range filters - use placeholders when values are null
// If only min is provided, set max to the dataset max
// If only max is provided, set min to the dataset min
const sliderMin = inputMin !== null ? inputMin : min;
const sliderMax = inputMax !== null ? inputMax : max;
return [sliderMin, sliderMax];
}, [inputValue, min, max, enableSingleValue]);
const updateDataMaskError = useCallback(
(errorMessage: string | null) => {
setDataMask({
@@ -206,39 +318,27 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
return;
}
if (
filterState.validateStatus === 'error' &&
error !== filterState.validateMessage
) {
setError(filterState.validateMessage);
const [inputMin, inputMax] = inputValue;
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);
// Only re-validate if message changed to prevents redundant validation
if (error !== filterState.validateMessage) {
const { isValid, errorMessage } = validateRange(
inputValue,
min,
max,
enableEmptyFilter,
enableSingleValue,
);
if (!isValid) {
setError(errorMessage);
updateDataMaskError(errorMessage);
} else {
setError(null);
updateDataMaskValue(inputValue);
}
}
return;
}
@@ -261,6 +361,7 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
}
}, [JSON.stringify(filterState.value)]);
// Get just the filter behavior text without the range information (which is shown in the tooltip)
const metadataText = useMemo(() => {
switch (enableSingleValue) {
case SingleValueType.Minimum:
@@ -270,19 +371,44 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
case SingleValueType.Exact:
return t('Filters for values equal to this exact value.');
default:
return '';
return null;
}
}, [enableSingleValue]);
const keyPressed = useRef(false);
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
keyPressed.current = !!/^[0-9]$/.test(event.key);
};
const handleChange = useCallback(
(newValue: number | null, index: 0 | 1) => {
if (row?.min === undefined && row?.max === undefined) {
return;
}
// When using increment/decrement buttons on an empty input,
// use the dataset min/max as the base value
let adjustedValue = newValue;
if (newValue !== null && inputValue[index] === null) {
if (keyPressed.current) {
adjustedValue = newValue;
keyPressed.current = false;
} else if (index === minIndex && newValue === 1) {
adjustedValue = min + 1;
} else if (index === minIndex && newValue === -1) {
adjustedValue = min - 1;
} else if (index === maxIndex && newValue === 1) {
adjustedValue = max + 1;
} else if (index === maxIndex && newValue === -1) {
adjustedValue = max - 1;
}
}
const newInputValue: [number | null, number | null] =
index === minIndex
? [newValue, inputValue[maxIndex]]
: [inputValue[minIndex], newValue];
? [adjustedValue, inputValue[maxIndex]]
: [inputValue[minIndex], adjustedValue];
setInputValue(newInputValue);
@@ -303,29 +429,116 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
setError(null);
updateDataMaskValue(newInputValue);
},
[col, min, max, enableEmptyFilter, enableSingleValue, setDataMask],
[
min,
max,
enableEmptyFilter,
enableSingleValue,
updateDataMaskError,
updateDataMaskValue,
inputValue,
],
);
const formItemExtra = useMemo(() => {
// Handler for slider change
const handleSliderChange = useCallback(
(value: number | number[]) => {
let newInputValue: RangeValue;
if (enableSingleValue !== undefined) {
const singleValue =
typeof value === 'number'
? value
: Array.isArray(value) && value.length > 0
? value[0]
: (min + max) / 2;
if (enableSingleValue === SingleValueType.Minimum) {
newInputValue = [singleValue, null];
} else if (enableSingleValue === SingleValueType.Maximum) {
newInputValue = [null, singleValue];
} else {
newInputValue = [singleValue, singleValue];
}
} else {
const arrayValue = Array.isArray(value) ? value : [min, max];
const [sliderMin, sliderMax] =
arrayValue.length >= 2 ? [arrayValue[0], arrayValue[1]] : [min, max];
newInputValue = [sliderMin, sliderMax];
}
setInputValue(newInputValue);
setError(null);
updateDataMaskValue(newInputValue);
},
[min, max, enableSingleValue, updateDataMaskValue],
);
const getMessageAndStatus = useCallback(() => {
const defaultMessage = t('Choose numbers between %(min)s and %(max)s', {
min,
max,
});
if (error) {
return <StatusMessage status="error">{error}</StatusMessage>;
return { message: error, status: 'error' as const };
}
return undefined;
}, [error]);
if (enableSingleValue !== undefined && metadataText) {
return { message: metadataText, status: 'help' as const };
}
return { message: defaultMessage, status: 'help' as const };
}, [error, min, max, enableSingleValue, metadataText]);
const MessageDisplay = useCallback(() => {
const { message, status } = getMessageAndStatus();
if (filterBarOrientation === FilterBarOrientation.Vertical) {
return <StatusMessage status={status}>{message}</StatusMessage>;
}
return null;
}, [getMessageAndStatus, filterBarOrientation]);
const InfoTooltip = useCallback(() => {
const { message, status } = getMessageAndStatus();
return (
<Tooltip title={message} placement="top">
<Icons.InfoCircleOutlined
iconSize="m"
iconColor={
status === 'error'
? theme.colors.error.base
: theme.colors.grayscale.base
}
className="tooltip-icon"
/>
</Tooltip>
);
}, [getMessageAndStatus]);
useEffect(() => {
switch (enableSingleValue) {
case SingleValueType.Minimum:
case SingleValueType.Exact:
handleChange(null, maxIndex);
break;
case SingleValueType.Maximum:
handleChange(null, minIndex);
break;
default:
break;
if (enableSingleValue !== undefined) {
switch (enableSingleValue) {
case SingleValueType.Minimum:
case SingleValueType.Exact:
if (inputValue[maxIndex] !== null) {
handleChange(null, maxIndex);
}
break;
case SingleValueType.Maximum:
if (inputValue[minIndex] !== null) {
handleChange(null, minIndex);
}
break;
default:
break;
}
}
// Reset data mask when single value mode changes
setDataMask({
extraFormData: {},
filterState: {
@@ -335,53 +548,123 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
});
}, [enableSingleValue]);
const renderSlider = () => {
if (enableSingleValue !== undefined) {
return (
<SliderWrapper>
<Slider
min={min}
max={max}
value={Array.isArray(sliderValue) ? sliderValue[0] : sliderValue}
onChange={handleSliderChange}
tooltip={{
formatter: val => (val !== null ? numberFormatter(val) : ''),
}}
/>
</SliderWrapper>
);
}
return (
<SliderWrapper data-test="range-filter-slider">
<Slider
min={min}
max={max}
range
value={Array.isArray(sliderValue) ? sliderValue : [min, sliderValue]}
onChange={handleSliderChange}
tooltip={{
formatter: val => (val !== null ? numberFormatter(val) : ''),
}}
/>
</SliderWrapper>
);
};
const renderInputs = () => (
<Wrapper
tabIndex={-1}
ref={inputRef}
onFocus={setFocusedFilter}
onBlur={unsetFocusedFilter}
onMouseEnter={setHoveredFilter}
onMouseLeave={unsetHoveredFilter}
onMouseDown={() => setFilterActive(true)}
onMouseUp={() => setFilterActive(false)}
>
{/* Conditionally render based on enableSingleValue */}
{(enableSingleValue === undefined ||
enableSingleValue === SingleValueType.Minimum ||
enableSingleValue === SingleValueType.Exact) && (
<InputNumber
value={inputValue[minIndex]}
onChange={val => handleChange(val, minIndex)}
onKeyDown={handleKeyDown}
placeholder={`${min}`}
style={{ width: '100%' }}
status={filterState.validateStatus}
data-test="range-filter-from-input"
/>
)}
{enableSingleValue === undefined && <StyledDivider>-</StyledDivider>}
{(enableSingleValue === undefined ||
enableSingleValue === SingleValueType.Maximum) && (
<InputNumber
value={inputValue[maxIndex]}
onChange={val => handleChange(val, maxIndex)}
onKeyDown={handleKeyDown}
placeholder={`${max}`}
style={{ width: '100%' }}
data-test="range-filter-to-input"
status={filterState.validateStatus}
/>
)}
</Wrapper>
);
return (
<FilterPluginStyle height={height} width={width}>
{Number.isNaN(Number(min)) || Number.isNaN(Number(max)) ? (
<h4>{t('Chosen non-numeric column')}</h4>
) : (
<FormItem
aria-labelledby={`filter-name-${formData.nativeFilterId}`}
extra={formItemExtra}
>
<Wrapper
tabIndex={-1}
ref={inputRef}
onFocus={setFocusedFilter}
onBlur={unsetFocusedFilter}
onMouseEnter={setHoveredFilter}
onMouseLeave={unsetHoveredFilter}
onMouseDown={() => setFilterActive(true)}
onMouseUp={() => setFilterActive(false)}
>
{(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"
/>
)}
{enableSingleValue === undefined && (
<StyledDivider>-</StyledDivider>
)}
{(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} />}
<FormItem aria-labelledby={`filter-name-${formData.nativeFilterId}`}>
{filterBarOrientation === FilterBarOrientation.Horizontal &&
!isOverflowingFilterBar ? (
<HorizontalLayout>
<div className="controls-container">
<InfoTooltip />
{(rangeDisplayMode === RangeDisplayMode.Slider ||
rangeDisplayMode === RangeDisplayMode.SliderAndInput) && (
<div className="slider-wrapper">
<div className="slider-container">{renderSlider()}</div>
</div>
)}
{(rangeDisplayMode === RangeDisplayMode.Input ||
rangeDisplayMode === RangeDisplayMode.SliderAndInput) && (
<div className="inputs-container">{renderInputs()}</div>
)}
</div>
</HorizontalLayout>
) : (
<>
<div style={{ position: 'relative' }}>
{isOverflowingFilterBar && (
<TooltipContainer>
<InfoTooltip />
</TooltipContainer>
)}
{(rangeDisplayMode === RangeDisplayMode.Slider ||
rangeDisplayMode === RangeDisplayMode.SliderAndInput) &&
renderSlider()}
{(rangeDisplayMode === RangeDisplayMode.Input ||
rangeDisplayMode === RangeDisplayMode.SliderAndInput) &&
renderInputs()}
<MessageDisplay />
</div>
</>
)}
</FormItem>
)}
</FilterPluginStyle>

View File

@@ -26,9 +26,16 @@ import { RefObject } from 'react';
import { PluginFilterHooks, PluginFilterStylesProps } from '../types';
import { FilterBarOrientation } from '../../../dashboard/types';
export enum RangeDisplayMode {
Slider = 'slider',
Input = 'input',
SliderAndInput = 'slider-and-input',
}
interface PluginFilterSelectCustomizeProps {
max?: number;
min?: number;
rangeDisplayMode?: RangeDisplayMode;
}
export type PluginFilterRangeQueryFormData = QueryFormData &

View File

@@ -48,6 +48,9 @@ const ControlContainer = styled.div<{
${({ validateStatus, theme }) =>
validateStatus && `border-color: ${theme.colors[validateStatus]?.base}`}
}
& > div {
width: 100%;
}
`;
export default function TimeFilterPlugin(props: PluginFilterTimeProps) {

View File

@@ -17,6 +17,7 @@
* under the License.
*/
import { styled } from '@superset-ui/core';
import { FormItem } from '@superset-ui/core/components';
import { PluginFilterStylesProps } from './types';
export const RESPONSIVE_WIDTH = 0;
@@ -26,8 +27,20 @@ export const FilterPluginStyle = styled.div<PluginFilterStylesProps>`
width: ${({ width }) => (width === RESPONSIVE_WIDTH ? '100%' : `${width}px`)};
`;
export const StatusMessage = styled.div<{
status?: 'error' | 'warning' | 'info';
}>`
color: ${({ theme, status = 'error' }) => theme.colors[status]?.base};
export const StyledFormItem = styled(FormItem)`
&.ant-row.ant-form-item {
margin: 0;
}
`;
export const StatusMessage = styled.div<{
status?: 'error' | 'warning' | 'info' | 'help';
centerText?: boolean;
}>`
color: ${({ theme, status = 'error' }) =>
status === 'help'
? theme.colors.grayscale.light1
: theme.colors[status]?.base};
text-align: ${({ centerText }) => (centerText ? 'center' : 'left')};
width: 100%;
`;