You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by di...@apache.org on 2025/07/04 10:31:30 UTC
(superset) branch master updated: feat(filter): Add Slider Range Inputs Option for Numerical Range Filters (#33170)
This is an automated email from the ASF dual-hosted git repository.
diegopucci pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new ef14a5fbb4 feat(filter): Add Slider Range Inputs Option for Numerical Range Filters (#33170)
ef14a5fbb4 is described below
commit ef14a5fbb47a540b0c91f40fa397fa9c6d18fa2d
Author: Pius Iniobong <in...@gmail.com>
AuthorDate: Fri Jul 4 11:31:23 2025 +0100
feat(filter): Add Slider Range Inputs Option for Numerical Range Filters (#33170)
Co-authored-by: Geido <60...@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41...@users.noreply.github.com>
Co-authored-by: GitHub Action <ac...@github.com>
Co-authored-by: Mehmet Salih Yavuz <sa...@proton.me>
---
.../dashboard/nativeFilters.noInitState.test.ts | 151 ++++--
.../cypress-base/cypress/e2e/dashboard/utils.ts | 21 +-
.../src/components/DropdownContainer/index.tsx | 133 +++++-
.../FilterBar/FilterControls/FilterControl.tsx | 4 +-
.../FiltersConfigForm/FiltersConfigForm.tsx | 186 ++++++--
.../components/nativeFilters/selectors.ts | 16 +-
.../components/Range/RangeFilterPlugin.test.tsx | 98 +++-
.../filters/components/Range/RangeFilterPlugin.tsx | 517 ++++++++++++++++-----
.../src/filters/components/Range/types.ts | 7 +
.../filters/components/Time/TimeFilterPlugin.tsx | 3 +
superset-frontend/src/filters/components/common.ts | 17 +-
11 files changed, 921 insertions(+), 232 deletions(-)
diff --git a/superset-frontend/cypress-base/cypress/e2e/dashboard/nativeFilters.noInitState.test.ts b/superset-frontend/cypress-base/cypress/e2e/dashboard/nativeFilters.noInitState.test.ts
index 60900ad934..6ca48c68c6 100644
--- a/superset-frontend/cypress-base/cypress/e2e/dashboard/nativeFilters.noInitState.test.ts
+++ b/superset-frontend/cypress-base/cypress/e2e/dashboard/nativeFilters.noInitState.test.ts
@@ -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([]);
+ describe.only('Numerical Range Filter - Display Modes', () => {
+ beforeEach(() => {
+ visitDashboard();
+ });
- // Assertions
- cy.get('[data-test="range-filter-from-input"]')
- .should('be.visible')
- .click();
+ 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('[data-test="range-filter-from-input"]').type('{selectall}40');
+ cy.get('.ant-collapse-content-box').should('be.visible');
+ };
- cy.get('[data-test="range-filter-to-input"]')
- .should('be.visible')
- .click();
+ const selectRangeTypeOption = (label: string) => {
+ cy.contains('Range Type')
+ .should('be.visible')
+ .closest('.ant-form-item')
+ .within(() => {
+ cy.get('.ant-select-selector').click();
+ });
- cy.get('[data-test="range-filter-to-input"]').type('{selectall}50');
- cy.get(nativeFilters.applyFilter).click({
- force: true,
- });
+ cy.get('.ant-select-dropdown:visible')
+ .contains('.ant-select-item-option', label)
+ .click();
+ };
- // Assert that the URL contains 'native_filters'
- cy.url().then(u => {
- const ur = new URL(u);
- expect(ur.search).to.include('native_filters');
+ 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');
});
});
diff --git a/superset-frontend/cypress-base/cypress/e2e/dashboard/utils.ts b/superset-frontend/cypress-base/cypress/e2e/dashboard/utils.ts
index 83326f965a..8ed39621ba 100644
--- a/superset-frontend/cypress-base/cypress/e2e/dashboard/utils.ts
+++ b/superset-frontend/cypress-base/cypress/e2e/dashboard/utils.ts
@@ -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);
+ });
}
/** ************************************************************************
diff --git a/superset-frontend/packages/superset-ui-core/src/components/DropdownContainer/index.tsx b/superset-frontend/packages/superset-ui-core/src/components/DropdownContainer/index.tsx
index b9835e3e13..7fab3e498c 100644
--- a/superset-frontend/packages/superset-ui-core/src/components/DropdownContainer/index.tsx
+++ b/superset-frontend/packages/superset-ui-core/src/components/DropdownContainer/index.tsx
@@ -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 };
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControl.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControl.tsx
index 97b4d31889..99a94c88d2 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControl.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControl.tsx
@@ -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 {
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx
index 20e22c4b83..8224f6aced 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx
@@ -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 (
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/selectors.ts b/superset-frontend/src/dashboard/components/nativeFilters/selectors.ts
index 40b362e86c..066737d7a1 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/selectors.ts
+++ b/superset-frontend/src/dashboard/components/nativeFilters/selectors.ts
@@ -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)
diff --git a/superset-frontend/src/filters/components/Range/RangeFilterPlugin.test.tsx b/superset-frontend/src/filters/components/Range/RangeFilterPlugin.test.tsx
index 855482027e..6cd53f52c4 100644
--- a/superset-frontend/src/filters/components/Range/RangeFilterPlugin.test.tsx
+++ b/superset-frontend/src/filters/components/Range/RangeFilterPlugin.test.tsx
@@ -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);
+ });
});
});
diff --git a/superset-frontend/src/filters/components/Range/RangeFilterPlugin.tsx b/superset-frontend/src/filters/components/Range/RangeFilterPlugin.tsx
index 0bd6e74d5a..8e6504d954 100644
--- a/superset-frontend/src/filters/components/Range/RangeFilterPlugin.tsx
+++ b/superset-frontend/src/filters/components/Range/RangeFilterPlugin.tsx
@@ -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
- ) {
+ if (filterState.validateStatus === 'error') {
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;
+ // 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);
+ }
}
-
- setError(null);
- updateDataMaskValue(inputValue);
- return;
- }
- if (filterState.validateStatus === 'error') {
- setError(filterState.validateMessage);
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,
+ ],
+ );
+
+ // 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 formItemExtra = useMemo(() => {
+ 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 };
+ }
+
+ if (enableSingleValue !== undefined && metadataText) {
+ return { message: metadataText, status: 'help' as const };
}
- return undefined;
- }, [error]);
+
+ 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>
diff --git a/superset-frontend/src/filters/components/Range/types.ts b/superset-frontend/src/filters/components/Range/types.ts
index 25a18bb3e8..d020ebf96d 100644
--- a/superset-frontend/src/filters/components/Range/types.ts
+++ b/superset-frontend/src/filters/components/Range/types.ts
@@ -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 &
diff --git a/superset-frontend/src/filters/components/Time/TimeFilterPlugin.tsx b/superset-frontend/src/filters/components/Time/TimeFilterPlugin.tsx
index 91a62e4c98..15d2c8b446 100644
--- a/superset-frontend/src/filters/components/Time/TimeFilterPlugin.tsx
+++ b/superset-frontend/src/filters/components/Time/TimeFilterPlugin.tsx
@@ -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) {
diff --git a/superset-frontend/src/filters/components/common.ts b/superset-frontend/src/filters/components/common.ts
index 2074309526..6ba9eabb14 100644
--- a/superset-frontend/src/filters/components/common.ts
+++ b/superset-frontend/src/filters/components/common.ts
@@ -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 StyledFormItem = styled(FormItem)`
+ &.ant-row.ant-form-item {
+ margin: 0;
+ }
+`;
+
export const StatusMessage = styled.div<{
- status?: 'error' | 'warning' | 'info';
+ status?: 'error' | 'warning' | 'info' | 'help';
+ centerText?: boolean;
}>`
- color: ${({ theme, status = 'error' }) => theme.colors[status]?.base};
+ color: ${({ theme, status = 'error' }) =>
+ status === 'help'
+ ? theme.colors.grayscale.light1
+ : theme.colors[status]?.base};
+ text-align: ${({ centerText }) => (centerText ? 'center' : 'left')};
+ width: 100%;
`;