mirror of
https://github.com/apache/superset.git
synced 2026-07-19 21:25:38 +00:00
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
/** ************************************************************************
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 &
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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%;
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user