You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ga...@apache.org on 2026/10/15 21:25:19 UTC
(superset) branch master updated: fix(filters): use manual semantic-view range bounds (#44999)
This is an automated email from the ASF dual-hosted git repository.
gabotorresruiz 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 d548e8dce9c fix(filters): use manual semantic-view range bounds (#44999)
d548e8dce9c is described below
commit d548e8dce9ca191fdc3d85ce2c7750cde52736d7
Author: Mike Bridge <mi...@preset.io>
AuthorDate: Thu Oct 15 17:25:11 2026 -0400
fix(filters): use manual semantic-view range bounds (#44999)
---
.../using-superset/semantic-member-selections.mdx | 10 +
.../FilterBar/FilterControls/FilterValue.test.tsx | 53 +++
.../FilterBar/FilterControls/FilterValue.tsx | 16 +-
.../FiltersConfigForm/FiltersConfigForm.tsx | 22 +-
.../FiltersConfigForm/getControlItemsMap.test.tsx | 19 +-
.../FiltersConfigForm/getControlItemsMap.tsx | 1 +
.../FiltersConfigForm/utils.test.ts | 41 ++
.../FiltersConfigModal/FiltersConfigForm/utils.ts | 27 +-
.../FiltersConfigModal/FiltersConfigModal.test.tsx | 40 ++
.../filters/components/Range/RangeChart.test.tsx | 115 +++++
.../components/Range/RangeFilterPlugin.test.tsx | 475 ++++++++++++++++++++-
.../filters/components/Range/RangeFilterPlugin.tsx | 195 ++++++---
.../filters/components/Range/buildQuery.test.ts | 50 +++
.../src/filters/components/Range/buildQuery.ts | 4 +
.../src/filters/components/Range/index.ts | 2 +
.../filters/components/Range/isSemanticRange.ts | 32 ++
.../src/filters/components/Range/transformProps.ts | 2 +-
.../src/utils/semanticViewStructure.test.ts | 50 +++
.../src/utils/semanticViewStructure.ts | 2 +-
superset/translations/messages.pot | 3 +
20 files changed, 1083 insertions(+), 76 deletions(-)
diff --git a/docs/docs/using-superset/semantic-member-selections.mdx b/docs/docs/using-superset/semantic-member-selections.mdx
index a901fee7423..4a1aa2774d6 100644
--- a/docs/docs/using-superset/semantic-member-selections.mdx
+++ b/docs/docs/using-superset/semantic-member-selections.mdx
@@ -102,3 +102,13 @@ predicates. Provider adapters must be upgraded before, or together with, the
host/SDK so that mixed selections preserve missing-value rows.
For adapter authors, see the [semantic filter contract](/developer-docs/extensions/semantic-filters).
+
+## Numerical range filters
+
+For semantic views, numerical range filters use **Minimum value** and
+**Maximum value** inputs instead of a slider. No query is sent to discover
+bounds. Leave either input empty for an unbounded end; leave both empty to
+remove the constraint unless **Filter value is required** is enabled.
+The minimum cannot exceed the maximum. Negative values and decimals are
+supported, and existing minimum, maximum, and exact single-value modes remain
+available. SQL dataset filters retain their discovered bounds and slider options.
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.test.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.test.tsx
index 4d9c9c7ef64..2fc6873948e 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.test.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.test.tsx
@@ -135,6 +135,29 @@ beforeEach(() => {
);
});
+test('semantic range renders without fetching bounds', async () => {
+ mockUseTransitiveParentIds.mockReturnValue([]);
+ mockUseFilterDependencies.mockReturnValue({});
+ mockRequestChartData.mockResolvedValue([{ data: [{ min: 1, max: 2 }] }]);
+ renderFilterValue({
+ filter: createMockFilter({
+ filterType: 'filter_range',
+ targets: [
+ {
+ datasetId: 3,
+ datasourceType: DatasourceType.SemanticView,
+ column: { name: 'Orders.age' },
+ },
+ ],
+ }),
+ });
+ await waitFor(() =>
+ expect(screen.getByTestId('mock-super-chart')).toBeInTheDocument(),
+ );
+ expect(mockRequestChartData).not.toHaveBeenCalled();
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
+});
+
test('renders loading spinner when filter has a data source', () => {
mockRequestChartData.mockReturnValue(new Promise(() => {}));
@@ -447,3 +470,33 @@ test('legacy permalink values require an explicit reset before the plugin can em
},
});
});
+
+test('semantic select still requests chart data for its options', async () => {
+ mockUseTransitiveParentIds.mockReturnValue([]);
+ mockUseFilterDependencies.mockReturnValue({});
+ mockRequestChartData.mockResolvedValue([
+ { data: [{ 'Orders.status': 'Open' }] },
+ ]);
+ renderFilterValue({
+ filter: createMockFilter({
+ filterType: 'filter_select',
+ targets: [
+ {
+ datasetId: 3,
+ datasourceType: DatasourceType.SemanticView,
+ column: { name: 'Orders.status' },
+ },
+ ],
+ }),
+ });
+ await waitFor(() => expect(mockRequestChartData).toHaveBeenCalled());
+ expect(mockRequestChartData.mock.calls[0][0].formData).toEqual(
+ expect.objectContaining({
+ datasource: '3__semantic_view',
+ viz_type: 'filter_select',
+ }),
+ );
+ await waitFor(() =>
+ expect(screen.getByTestId('mock-super-chart')).toBeInTheDocument(),
+ );
+});
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.tsx
index 71849c6bc5d..a10aafd498f 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterValue.tsx
@@ -61,6 +61,7 @@ import {
unsetHoveredChartCustomization,
} from 'src/dashboard/actions/nativeFilters';
import { RESPONSIVE_WIDTH } from 'src/filters/components/common';
+import isSemanticRange from 'src/filters/components/Range/isSemanticRange';
import { dispatchHoverAction, dispatchFocusAction } from './utils';
import { FilterControlProps } from './types';
import { getFormData } from '../../utils';
@@ -286,12 +287,15 @@ const FilterValue: FC<FilterValueProps> = ({
return;
}
setIsRefreshing(true);
- requestChartDataResolved({
- formData: newFormData,
- force: shouldRefresh,
- ownState: filterOwnState,
- requestParams: { async_mode_override: asyncModeOverride },
- })
+ const query = isSemanticRange(newFormData)
+ ? Promise.resolve(queriesDataPlaceholder)
+ : requestChartDataResolved({
+ formData: newFormData,
+ force: shouldRefresh,
+ ownState: filterOwnState,
+ requestParams: { async_mode_override: asyncModeOverride },
+ });
+ query
.then(queriesResponse => {
setState(queriesResponse as ChartDataResponseResult[]);
setError(undefined);
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 5144976fae7..ec9b34a899a 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx
@@ -63,6 +63,7 @@ import {
} from 'src/filters/components/Select/types';
import { useSelector } from 'react-redux';
import { requestChartDataResolved } from 'src/components/Chart/chartAction';
+import isSemanticRange from 'src/filters/components/Range/isSemanticRange';
import {
Constants,
Button,
@@ -572,6 +573,7 @@ const FiltersConfigForm = (
dashboardId,
groupby: formFilter?.column,
...formFilter,
+ filterType: itemTypeField,
});
formData.extra_form_data = dependenciesDefaultValues;
@@ -579,11 +581,15 @@ const FiltersConfigForm = (
defaultValueQueriesData: null,
isDataDirty: false,
});
- requestChartDataResolved({
- formData,
- force,
- requestParams: { async_mode_override: asyncModeOverride },
- })
+ // Manual semantic ranges need no provider bounds, including defaults.
+ const query = isSemanticRange(formData)
+ ? Promise.resolve([{ data: [{}] }])
+ : requestChartDataResolved({
+ formData,
+ force,
+ requestParams: { async_mode_override: asyncModeOverride },
+ });
+ query
.then(queriesResponse => {
setNativeFilterFieldValuesWrapper({
defaultValueQueriesData:
@@ -598,6 +604,8 @@ const FiltersConfigForm = (
},
[
datasetId,
+ datasourceType,
+ itemTypeField,
dashboardId,
filterId,
forceUpdate,
@@ -1974,6 +1982,10 @@ const FiltersConfigForm = (
<FormItem
name={['filters', filterId, 'rangeType']}
+ hidden={
+ datasourceType ===
+ DatasourceType.SemanticView
+ }
>
<StyledRowFormItem
expanded={expanded}
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/getControlItemsMap.test.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/getControlItemsMap.test.tsx
index 99adca28e22..079dec1cf15 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/getControlItemsMap.test.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/getControlItemsMap.test.tsx
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Filter, NativeFilterType } from '@superset-ui/core';
+import { DatasourceType, Filter, NativeFilterType } from '@superset-ui/core';
import { render, screen, userEvent } from 'spec/helpers/testing-library';
import type { FormInstance } from '@superset-ui/core/components';
import getControlItemsMap, { ControlItemsProps } from './getControlItemsMap';
@@ -262,4 +262,21 @@ describe('ColumnSelect filterValues behavior', () => {
expect(screen.queryByText('col3')).not.toBeInTheDocument();
expect(screen.queryByText('col2')).not.toBeInTheDocument();
});
+ test('passes the datasource type so semantic range filters can require numeric columns', () => {
+ (doesColumnMatchFilterType as jest.Mock).mockReturnValue(true);
+ const props = {
+ ...createProps(),
+ datasourceType: DatasourceType.SemanticView,
+ formFilter: { filterType: 'filter_range' },
+ };
+ // @ts-expect-error: bypass incomplete formFilter type for test
+ const element = getControlItemsMap(props).mainControlItems.groupby
+ .element as React.ReactElement;
+ render(element);
+ expect(doesColumnMatchFilterType).toHaveBeenCalledWith(
+ 'filter_range',
+ expect.objectContaining({ name: 'col1' }),
+ DatasourceType.SemanticView,
+ );
+ });
});
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/getControlItemsMap.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/getControlItemsMap.tsx
index 9404d5c9019..a5d5b7969a4 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/getControlItemsMap.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/getControlItemsMap.tsx
@@ -147,6 +147,7 @@ export default function getControlItemsMap({
doesColumnMatchFilterType(
formFilter?.filterType || '',
column,
+ datasourceType,
) && !!column?.filterable
}
onChange={() => {
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/utils.test.ts b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/utils.test.ts
index 70334eea1e5..3ed7c20d909 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/utils.test.ts
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/utils.test.ts
@@ -293,6 +293,47 @@ test('doesColumnMatchFilterType returns false when column type does not match fi
expect(doesColumnMatchFilterType('filter_range', stringColumn)).toBe(false);
});
+test('doesColumnMatchFilterType offers only numeric semantic dimensions to filter_range', () => {
+ const numeric = createColumn('revenue', GenericDataType.Numeric);
+ const untyped = createColumn('payload');
+ const string = createColumn('region', GenericDataType.String);
+ const temporal = createColumn('ordered_at', GenericDataType.Temporal);
+ const semantic = DatasourceType.SemanticView;
+ expect(doesColumnMatchFilterType('filter_range', numeric, semantic)).toBe(
+ true,
+ );
+ // A semantic range filter has no runtime non-numeric check, so an
+ // unrecognised type must not be offered for >= / <= comparisons.
+ expect(doesColumnMatchFilterType('filter_range', untyped, semantic)).toBe(
+ false,
+ );
+ expect(doesColumnMatchFilterType('filter_range', string, semantic)).toBe(
+ false,
+ );
+ expect(doesColumnMatchFilterType('filter_range', temporal, semantic)).toBe(
+ false,
+ );
+});
+
+test('doesColumnMatchFilterType keeps untyped dataset columns for filter_range', () => {
+ const untyped = createColumn('payload');
+ expect(
+ doesColumnMatchFilterType('filter_range', untyped, DatasourceType.Table),
+ ).toBe(true);
+ expect(doesColumnMatchFilterType('filter_range', untyped)).toBe(true);
+});
+
+test('doesColumnMatchFilterType keeps untyped semantic dimensions for other filters', () => {
+ const untyped = createColumn('payload');
+ expect(
+ doesColumnMatchFilterType(
+ 'filter_select',
+ untyped,
+ DatasourceType.SemanticView,
+ ),
+ ).toBe(true);
+});
+
test('doesColumnMatchFilterType returns true when column type matches filter_time', () => {
const temporalColumn = createColumn('created_at', GenericDataType.Temporal);
expect(doesColumnMatchFilterType('filter_time', temporalColumn)).toBe(true);
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/utils.ts b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/utils.ts
index 33fa45568fa..91647a70db0 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/utils.ts
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/utils.ts
@@ -94,12 +94,27 @@ export const shouldShowTimeRangePicker = (
currentDataset: (Dataset & { column_types: GenericDataType[] }) | undefined,
): boolean => (currentDataset ? hasTemporalColumns(currentDataset) : true);
-export const doesColumnMatchFilterType = (filterType: string, column: Column) =>
- !column.type_generic ||
- !(filterType in FILTER_SUPPORTED_TYPES) ||
- FILTER_SUPPORTED_TYPES[
- filterType as keyof typeof FILTER_SUPPORTED_TYPES
- ]?.includes(column.type_generic);
+export const doesColumnMatchFilterType = (
+ filterType: string,
+ column: Column,
+ datasourceType?: DatasourceType,
+): boolean => {
+ // Semantic range filters skip the runtime non-numeric bounds check, so an
+ // unrecognised dimension type must not be offered for >= / <= comparisons.
+ if (
+ filterType === 'filter_range' &&
+ datasourceType === DatasourceType.SemanticView
+ ) {
+ return column.type_generic === GenericDataType.Numeric;
+ }
+ return (
+ !column.type_generic ||
+ !(filterType in FILTER_SUPPORTED_TYPES) ||
+ !!FILTER_SUPPORTED_TYPES[
+ filterType as keyof typeof FILTER_SUPPORTED_TYPES
+ ]?.includes(column.type_generic)
+ );
+};
// Shared semantic-view structure helpers live in a layer-neutral module so
// non-dashboard consumers (e.g. the dataset drill-info hook) need not import
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal.test.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal.test.tsx
index 0db87c7d7a2..8498d6e7859 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal.test.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal.test.tsx
@@ -1172,6 +1172,46 @@ test('toggles "Filter has default value" to show and hide the Default Value cont
});
});
+test('semantic range defaults use manual inputs without a chart-data bounds request', async () => {
+ fetchMock.clearHistory();
+ fetchMock.get('glob:*/api/v1/semantic_view/988/structure', {
+ result: {
+ name: 'Orders',
+ dimensions: [{ name: 'Orders.age', type: 'number' }],
+ metrics: [],
+ },
+ });
+ const filter = {
+ ...buildNativeFilter('NATIVE_FILTER-range', 'Age', []),
+ filterType: 'filter_range',
+ targets: [
+ {
+ datasetId: 988,
+ datasourceType: DatasourceType.SemanticView,
+ column: { name: 'Orders.age' },
+ },
+ ],
+ controlValues: { enableSingleValue: undefined },
+ defaultDataMask: { filterState: { value: [18, 65] }, extraFormData: {} },
+ };
+ const state = {
+ ...defaultState(),
+ dashboardInfo: { metadata: { native_filter_configuration: [filter] } },
+ dashboardLayout,
+ };
+ defaultRender(state, { ...props, createNewOnOpen: false });
+ expect(
+ await screen.findByRole('spinbutton', { name: 'Minimum value' }),
+ ).toHaveValue('18');
+ expect(screen.getByRole('spinbutton', { name: 'Maximum value' })).toHaveValue(
+ '65',
+ );
+ expect(screen.queryByRole('slider')).not.toBeInTheDocument();
+ expect(fetchMock.callHistory.calls('glob:*/api/v1/chart/data')).toHaveLength(
+ 0,
+ );
+}, 30000);
+
test('semantic filter reset requires reselection and survives save and reopen', async () => {
fetchMock.get('glob:*/api/v1/semantic_view/987/structure', {
result: {
diff --git a/superset-frontend/src/filters/components/Range/RangeChart.test.tsx b/superset-frontend/src/filters/components/Range/RangeChart.test.tsx
new file mode 100644
index 00000000000..e08564210fd
--- /dev/null
+++ b/superset-frontend/src/filters/components/Range/RangeChart.test.tsx
@@ -0,0 +1,115 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import {
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from 'spec/helpers/testing-library';
+import { GenericDataType } from '@apache-superset/core/common';
+import ChartRenderer, {
+ type ChartRendererProps,
+} from 'src/components/Chart/ChartRenderer';
+import { ChartSource } from 'src/types/ChartSource';
+import RangeFilterPlugin from './index';
+
+const plugin = new RangeFilterPlugin().configure({ key: 'filter_range' });
+
+beforeAll(() => plugin.register());
+afterAll(() => plugin.unregister());
+
+function chartProps(datasource: string): ChartRendererProps {
+ return {
+ chartId: 17,
+ chartStatus: 'success',
+ width: 700,
+ height: 400,
+ vizType: 'filter_range',
+ source: ChartSource.Explore,
+ formData: {
+ viz_type: 'filter_range',
+ datasource,
+ groupby: ['amount'],
+ enableEmptyFilter: false,
+ rangeDisplayMode: 'slider',
+ },
+ filterState: { value: [null, null] },
+ queriesResponse: [],
+ actions: {
+ chartRenderingSucceeded: jest.fn(),
+ chartRenderingFailed: jest.fn(),
+ logEvent: jest.fn(),
+ updateDataMask: jest.fn(),
+ },
+ };
+}
+
+test('standalone semantic range renders manual inputs through the chart wrapper with no query results', async () => {
+ const props = chartProps('1__semantic_view');
+ render(<ChartRenderer {...props} />, { useRedux: true });
+ const inputs = await screen.findAllByRole('spinbutton');
+ expect(inputs).toHaveLength(2);
+ expect(screen.queryByRole('slider')).not.toBeInTheDocument();
+ expect(
+ screen.queryByText('No results were returned for this query'),
+ ).not.toBeInTheDocument();
+ fireEvent.change(inputs[0], { target: { value: '5' } });
+ fireEvent.change(inputs[1], { target: { value: '7' } });
+ await waitFor(() =>
+ expect(props.actions.updateDataMask).toHaveBeenLastCalledWith(
+ 17,
+ expect.objectContaining({
+ extraFormData: expect.objectContaining({
+ filters: [
+ { col: 'amount', op: '>=', val: 5 },
+ { col: 'amount', op: '<=', val: 7 },
+ ],
+ }),
+ }),
+ ),
+ );
+});
+
+test('standalone SQL range keeps its slider when bounds exist', async () => {
+ const props = chartProps('1__table');
+ render(
+ <ChartRenderer
+ {...props}
+ queriesResponse={
+ [
+ {
+ data: [{ min: 3, max: 10 }],
+ colnames: ['min', 'max'],
+ coltypes: [GenericDataType.Numeric, GenericDataType.Numeric],
+ rowcount: 1,
+ },
+ ] as ChartRendererProps['queriesResponse']
+ }
+ />,
+ { useRedux: true },
+ );
+ expect(await screen.findAllByRole('slider')).toHaveLength(2);
+ expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument();
+});
+
+test('standalone SQL range keeps its empty-result inputs usable', async () => {
+ render(<ChartRenderer {...chartProps('1__table')} />, { useRedux: true });
+ expect(await screen.findAllByRole('spinbutton')).toHaveLength(2);
+ expect(screen.queryByRole('slider')).not.toBeInTheDocument();
+});
diff --git a/superset-frontend/src/filters/components/Range/RangeFilterPlugin.test.tsx b/superset-frontend/src/filters/components/Range/RangeFilterPlugin.test.tsx
index 39af8cdfd04..3ac0e8fa474 100644
--- a/superset-frontend/src/filters/components/Range/RangeFilterPlugin.test.tsx
+++ b/superset-frontend/src/filters/components/Range/RangeFilterPlugin.test.tsx
@@ -19,7 +19,7 @@
import { AppSection, type ChartProps } from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import userEvent from '@testing-library/user-event';
-import { render, screen } from 'spec/helpers/testing-library';
+import { render, screen, fireEvent } from 'spec/helpers/testing-library';
import RangeFilterPlugin, { calculateStep } from './RangeFilterPlugin';
import { RangeDisplayMode, type PluginFilterRangeProps } from './types';
import { SingleValueType } from './SingleValueType';
@@ -288,6 +288,26 @@ describe('RangeFilterPlugin', () => {
expect(screen.queryAllByRole('slider')).toHaveLength(0);
});
+ test('dataset input mode emits an exact filter for a typed exact value', () => {
+ getWrapper({
+ formData: {
+ rangeDisplayMode: RangeDisplayMode.Input,
+ enableSingleValue: SingleValueType.Exact,
+ },
+ filterState: { value: [10, 10] },
+ });
+ fireEvent.change(screen.getByRole('spinbutton'), {
+ target: { value: '42' },
+ });
+ expect(setDataMask).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ extraFormData: {
+ filters: [{ col: 'SP_POP_TOTL', op: '==', val: 42 }],
+ },
+ }),
+ );
+ });
+
test('should render both slider and inputs in slider-and-input mode', () => {
getWrapper({
formData: {
@@ -503,7 +523,460 @@ test('calculateStep never returns 0 for tiny ranges', () => {
expect(calculateStep(0, 0.0000001)).toBeGreaterThan(0);
});
+test('semantic range renders labelled manual bounds without query results or a slider', async () => {
+ const setDataMask = jest.fn();
+ render(
+ <RangeFilterPlugin
+ {...(transformProps({
+ ...rangeProps,
+ queriesData: [],
+ formData: {
+ ...rangeProps.formData,
+ datasource: '3__semantic_view',
+ defaultValue: undefined,
+ rangeDisplayMode: RangeDisplayMode.Slider,
+ },
+ filterState: { value: [null, null] },
+ } as unknown as ChartProps) as PluginFilterRangeProps)}
+ setDataMask={setDataMask}
+ />,
+ );
+ const lower = screen.getByRole('spinbutton', { name: 'Minimum value' });
+ const upper = screen.getByRole('spinbutton', { name: 'Maximum value' });
+ expect(screen.queryByRole('slider')).not.toBeInTheDocument();
+ expect(
+ screen.getByText('Leave a bound empty for an unbounded range.'),
+ ).toBeInTheDocument();
+ expect(lower).toHaveValue('');
+ expect(upper).toHaveValue('');
+ fireEvent.change(lower, { target: { value: '-1.5' } });
+ fireEvent.blur(lower);
+ expect(setDataMask).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ extraFormData: { filters: [{ col: 'SP_POP_TOTL', op: '>=', val: -1.5 }] },
+ filterState: expect.objectContaining({
+ value: [-1.5, null],
+ validateStatus: undefined,
+ }),
+ }),
+ );
+ fireEvent.change(upper, { target: { value: '2000000.25' } });
+ fireEvent.blur(upper);
+ expect(setDataMask).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ extraFormData: {
+ filters: [
+ { col: 'SP_POP_TOTL', op: '>=', val: -1.5 },
+ { col: 'SP_POP_TOTL', op: '<=', val: 2000000.25 },
+ ],
+ },
+ }),
+ );
+ await userEvent.clear(lower);
+ await userEvent.clear(upper);
+ expect(setDataMask).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ extraFormData: {},
+ filterState: expect.objectContaining({
+ value: [null, null],
+ validateStatus: undefined,
+ }),
+ }),
+ );
+});
+
test('calculateStep falls back for non-positive ranges', () => {
expect(calculateStep(5, 5)).toBe(0.01);
expect(calculateStep(10, 5)).toBe(0.01);
});
+
+function semanticRangeProps(
+ overrides: Partial<PluginFilterRangeProps> = {},
+): PluginFilterRangeProps {
+ return {
+ ...(transformProps({
+ ...rangeProps,
+ queriesData: [],
+ formData: {
+ ...rangeProps.formData,
+ datasource: '3__semantic_view',
+ defaultValue: undefined,
+ },
+ filterState: { value: [null, null] },
+ } as unknown as ChartProps) as PluginFilterRangeProps),
+ ...overrides,
+ };
+}
+
+test('semantic range rejects reversed bounds and recovers when corrected', () => {
+ const setDataMask = jest.fn();
+ render(
+ <RangeFilterPlugin {...semanticRangeProps()} setDataMask={setDataMask} />,
+ );
+ const [lower, upper] = screen.getAllByRole('spinbutton');
+ fireEvent.change(lower, { target: { value: '20' } });
+ fireEvent.change(upper, { target: { value: '10' } });
+ expect(setDataMask).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ extraFormData: {},
+ filterState: expect.objectContaining({ validateStatus: 'error' }),
+ }),
+ );
+ expect(
+ screen.getByText('Min value cannot be greater than max value'),
+ ).toBeInTheDocument();
+ fireEvent.change(upper, { target: { value: '30' } });
+ expect(setDataMask).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ filterState: expect.objectContaining({
+ value: [20, 30],
+ validateStatus: undefined,
+ }),
+ }),
+ );
+});
+
+test('semantic range validates required empty saved state without discovered bounds', () => {
+ const props = semanticRangeProps();
+ const setDataMask = jest.fn();
+ render(
+ <RangeFilterPlugin
+ {...props}
+ formData={{ ...props.formData, enableEmptyFilter: true }}
+ setDataMask={setDataMask}
+ />,
+ );
+ expect(setDataMask).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ extraFormData: {},
+ filterState: expect.objectContaining({ validateStatus: 'error' }),
+ }),
+ );
+});
+
+test.each([
+ [SingleValueType.Minimum, [5, null], '>='],
+ [SingleValueType.Maximum, [null, 5], '<='],
+ [SingleValueType.Exact, [5, 5], '=='],
+] as const)(
+ 'semantic single-value mode %s preserves saved defaults and emits the right operator',
+ (mode, value, op) => {
+ const props = semanticRangeProps();
+ const setDataMask = jest.fn();
+ render(
+ <RangeFilterPlugin
+ {...props}
+ formData={{
+ ...props.formData,
+ enableSingleValue: mode,
+ defaultValue: [...value],
+ }}
+ filterState={{ value: [...value] }}
+ setDataMask={setDataMask}
+ />,
+ );
+ expect(screen.getAllByRole('spinbutton')).toHaveLength(1);
+ expect(screen.getByRole('spinbutton')).toHaveValue('5');
+ expect(setDataMask).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ extraFormData: { filters: [{ col: 'SP_POP_TOTL', op, val: 5 }] },
+ }),
+ );
+ fireEvent.change(screen.getByRole('spinbutton'), {
+ target: { value: '-2.5' },
+ });
+ expect(setDataMask).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ extraFormData: { filters: [{ col: 'SP_POP_TOTL', op, val: -2.5 }] },
+ }),
+ );
+ },
+);
+
+test('semantic range restores external values and clears a saved default', () => {
+ const props = semanticRangeProps();
+ const setDataMask = jest.fn();
+ const element = (value: [number | null, number | null]) => (
+ <RangeFilterPlugin
+ {...props}
+ formData={{ ...props.formData, defaultValue: [10, 70] }}
+ filterState={{ value }}
+ setDataMask={setDataMask}
+ />
+ );
+ const { rerender } = render(element([10, 70]));
+ rerender(element([null, null]));
+ expect(
+ screen
+ .getAllByRole('spinbutton')
+ .map(input => (input as HTMLInputElement).value),
+ ).toEqual(['', '']);
+ expect(setDataMask).toHaveBeenLastCalledWith(
+ expect.objectContaining({ extraFormData: {} }),
+ );
+ rerender(element([0, 0]));
+ expect(setDataMask).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ extraFormData: { filters: [{ col: 'SP_POP_TOTL', op: '==', val: 0 }] },
+ }),
+ );
+});
+
+test('semantic range clears a required-value error when made optional', () => {
+ const props = semanticRangeProps();
+ const setDataMask = jest.fn();
+ const element = (required: boolean, invalid: boolean) => (
+ <RangeFilterPlugin
+ {...props}
+ formData={{ ...props.formData, enableEmptyFilter: required }}
+ filterState={{
+ value: [null, null],
+ validateStatus: invalid ? 'error' : undefined,
+ validateMessage: invalid
+ ? 'Please provide a valid min or max value'
+ : '',
+ }}
+ setDataMask={setDataMask}
+ />
+ );
+ const { rerender } = render(element(true, false));
+ rerender(element(true, true));
+ setDataMask.mockClear();
+ rerender(element(false, true));
+ expect(setDataMask).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ extraFormData: {},
+ filterState: expect.objectContaining({
+ value: [null, null],
+ validateStatus: undefined,
+ }),
+ }),
+ );
+ expect(
+ screen.queryByText('Please provide a valid min or max value'),
+ ).not.toBeInTheDocument();
+});
+
+test.each([undefined, SingleValueType.Exact])(
+ 'semantic range rejects non-finite manual input in mode %s',
+ mode => {
+ const props = semanticRangeProps();
+ const setDataMask = jest.fn();
+ render(
+ <RangeFilterPlugin
+ {...props}
+ formData={{ ...props.formData, enableSingleValue: mode }}
+ setDataMask={setDataMask}
+ />,
+ );
+ fireEvent.change(
+ screen.getAllByRole('spinbutton')[mode === undefined ? 1 : 0],
+ { target: { value: '9'.repeat(309) } },
+ );
+ expect(setDataMask).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ extraFormData: {},
+ filterState: expect.objectContaining({ validateStatus: 'error' }),
+ }),
+ );
+ },
+);
+
+test.each([
+ ['an all-null numeric column', [{ min: null, max: null }]],
+ ['an empty result', []],
+])('keeps unbounded inputs usable for %s', (_label, data) => {
+ const setDataMask = jest.fn();
+ render(
+ <RangeFilterPlugin
+ {...(transformProps({
+ ...rangeProps,
+ queriesData: [{ ...rangeProps.queriesData[0], rowcount: 0, data }],
+ formData: { ...rangeProps.formData, defaultValue: undefined },
+ filterState: { value: [null, null] },
+ } as unknown as ChartProps) as PluginFilterRangeProps)}
+ setDataMask={setDataMask}
+ />,
+ );
+ expect(
+ screen.queryByText('Chosen non-numeric column'),
+ ).not.toBeInTheDocument();
+ expect(screen.queryByRole('slider')).not.toBeInTheDocument();
+ const [lower, upper] = screen.getAllByRole('spinbutton');
+ expect(lower).toHaveAttribute('placeholder', '');
+ expect(upper).toHaveAttribute('placeholder', '');
+ expect(
+ screen.getByText('Leave a bound empty for an unbounded range.'),
+ ).toBeInTheDocument();
+ fireEvent.change(lower, { target: { value: '-3' } });
+ fireEvent.blur(lower);
+ expect(setDataMask).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ extraFormData: { filters: [{ col: 'SP_POP_TOTL', op: '>=', val: -3 }] },
+ }),
+ );
+});
+
+test('flags a column whose aggregates are not numeric', () => {
+ render(
+ <RangeFilterPlugin
+ {...(transformProps({
+ ...rangeProps,
+ queriesData: [
+ { ...rangeProps.queriesData[0], data: [{ min: 'a', max: 'z' }] },
+ ],
+ } as unknown as ChartProps) as PluginFilterRangeProps)}
+ setDataMask={jest.fn()}
+ />,
+ );
+ expect(screen.getByText('Chosen non-numeric column')).toBeInTheDocument();
+});
+
+test.each([
+ SingleValueType.Minimum,
+ SingleValueType.Maximum,
+ SingleValueType.Exact,
+])('optional semantic single-value mode %s accepts Clear All', mode => {
+ const props = semanticRangeProps();
+ const setDataMask = jest.fn();
+ const element = (value: [number | null, number | null]) => (
+ <RangeFilterPlugin
+ {...props}
+ formData={{
+ ...props.formData,
+ enableSingleValue: mode,
+ enableEmptyFilter: false,
+ }}
+ filterState={{ value }}
+ setDataMask={setDataMask}
+ />
+ );
+ const { rerender } = render(element([5, 5]));
+ rerender(element([null, null]));
+ expect(screen.getByRole('spinbutton')).toHaveValue('');
+ expect(setDataMask).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ extraFormData: {},
+ filterState: expect.objectContaining({
+ value: [null, null],
+ validateStatus: undefined,
+ }),
+ }),
+ );
+ expect(
+ screen.queryByText('Please provide a valid min or max value'),
+ ).not.toBeInTheDocument();
+});
+
+test.each([
+ SingleValueType.Minimum,
+ SingleValueType.Maximum,
+ SingleValueType.Exact,
+])('required semantic single-value mode %s rejects Clear All', mode => {
+ const props = semanticRangeProps();
+ const setDataMask = jest.fn();
+ const element = (value: [number | null, number | null]) => (
+ <RangeFilterPlugin
+ {...props}
+ formData={{
+ ...props.formData,
+ enableSingleValue: mode,
+ enableEmptyFilter: true,
+ }}
+ filterState={{ value }}
+ setDataMask={setDataMask}
+ />
+ );
+ const { rerender } = render(element([5, 5]));
+ rerender(element([null, null]));
+ expect(setDataMask).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ extraFormData: {},
+ filterState: expect.objectContaining({ validateStatus: 'error' }),
+ }),
+ );
+ expect(
+ screen.getByText('Please provide a valid min or max value'),
+ ).toBeInTheDocument();
+});
+
+test.each([
+ ['an empty SQL column', []],
+ ['an all-null SQL column', [{ min: null, max: null }]],
+])('saved Slider mode falls back to usable inputs for %s', (_label, data) => {
+ const setDataMask = jest.fn();
+ render(
+ <RangeFilterPlugin
+ {...(transformProps({
+ ...rangeProps,
+ queriesData: [{ ...rangeProps.queriesData[0], rowcount: 0, data }],
+ formData: {
+ ...rangeProps.formData,
+ rangeDisplayMode: RangeDisplayMode.Slider,
+ defaultValue: undefined,
+ },
+ filterState: { value: [null, null] },
+ } as unknown as ChartProps) as PluginFilterRangeProps)}
+ setDataMask={setDataMask}
+ />,
+ );
+ expect(screen.queryByRole('slider')).not.toBeInTheDocument();
+ const [lower, upper] = screen.getAllByRole('spinbutton');
+ expect(lower).toBeInTheDocument();
+ expect(upper).toBeInTheDocument();
+ fireEvent.change(lower, { target: { value: '-3' } });
+ fireEvent.blur(lower);
+ expect(setDataMask).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ extraFormData: { filters: [{ col: 'SP_POP_TOTL', op: '>=', val: -3 }] },
+ }),
+ );
+});
+
+test.each(
+ [
+ SingleValueType.Minimum,
+ SingleValueType.Maximum,
+ SingleValueType.Exact,
+ ].flatMap(mode => [false, true].map(required => ({ mode, required }))),
+)(
+ 'SQL single-value mode $mode validates cleared input with required=$required',
+ async ({ mode, required }) => {
+ const setDataMask = jest.fn();
+ const props = transformProps({
+ ...rangeProps,
+ formData: {
+ ...rangeProps.formData,
+ datasource: '3__table',
+ enableSingleValue: mode,
+ enableEmptyFilter: required,
+ },
+ } as unknown as ChartProps) as PluginFilterRangeProps;
+ render(<RangeFilterPlugin {...props} setDataMask={setDataMask} />);
+ await userEvent.clear(screen.getByRole('spinbutton'));
+ if (required) {
+ expect(setDataMask).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ extraFormData: {},
+ filterState: expect.objectContaining({ validateStatus: 'error' }),
+ }),
+ );
+ expect(
+ screen.getByText('Please provide a valid min or max value'),
+ ).toBeInTheDocument();
+ } else {
+ expect(setDataMask).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ extraFormData: {},
+ filterState: expect.objectContaining({
+ value: [null, null],
+ validateStatus: undefined,
+ }),
+ }),
+ );
+ expect(
+ screen.queryByText('Please provide a valid min or max value'),
+ ).not.toBeInTheDocument();
+ }
+ },
+);
diff --git a/superset-frontend/src/filters/components/Range/RangeFilterPlugin.tsx b/superset-frontend/src/filters/components/Range/RangeFilterPlugin.tsx
index a414491d6bc..157e5489c37 100644
--- a/superset-frontend/src/filters/components/Range/RangeFilterPlugin.tsx
+++ b/superset-frontend/src/filters/components/Range/RangeFilterPlugin.tsx
@@ -36,6 +36,7 @@ import { PluginFilterRangeProps, RangeDisplayMode } from './types';
import { StatusMessage, FilterPluginStyle } from '../common';
import { getRangeExtraFormData } from '../../utils';
import { SingleValueType } from './SingleValueType';
+import isSemanticRange from './isSemanticRange';
type InputValue = number | null;
type RangeValue = [InputValue, InputValue];
@@ -163,8 +164,8 @@ export const calculateStep = (minValue: number, maxValue: number): number => {
const validateRange = (
values: RangeValue,
- min: number,
- max: number,
+ min: number | undefined,
+ max: number | undefined,
enableEmptyFilter: boolean,
enableSingleValue?: SingleValueType,
): { isValid: boolean; errorMessage: string | null } => {
@@ -176,6 +177,23 @@ const validateRange = (
max,
});
+ if (values.some(value => value !== null && !Number.isFinite(value))) {
+ return { isValid: false, errorMessage: requiredError };
+ }
+
+ // Allow empty ranges if enableEmptyFilter is false
+ if (!enableEmptyFilter && inputMin === null && inputMax === null) {
+ return { isValid: true, errorMessage: null };
+ }
+
+ // If enableEmptyFilter is true, at least one value is required
+ if (enableEmptyFilter && inputMin === null && inputMax === null) {
+ return {
+ isValid: false,
+ errorMessage: requiredError,
+ };
+ }
+
// Single value validation
if (enableSingleValue !== undefined) {
const isSingleMin =
@@ -187,7 +205,10 @@ const validateRange = (
return { isValid: false, errorMessage: requiredError };
}
- if (isNumber(value) && (value < min || value > max)) {
+ if (
+ (min !== undefined && value < min) ||
+ (max !== undefined && value > max)
+ ) {
return { isValid: false, errorMessage: rangeError };
}
@@ -195,19 +216,6 @@ const validateRange = (
}
// Range validation
- // Allow empty ranges if enableEmptyFilter is false
- if (!enableEmptyFilter && inputMin === null && inputMax === null) {
- return { isValid: true, errorMessage: null };
- }
-
- // If enableEmptyFilter is true, at least one value is required
- if (enableEmptyFilter && inputMin === null && inputMax === null) {
- return {
- isValid: false,
- errorMessage: requiredError,
- };
- }
-
// Check relationship between min and max when both are provided
if (inputMin !== null && inputMax !== null && inputMin > inputMax) {
return {
@@ -218,8 +226,8 @@ const validateRange = (
// Check individual value bounds if provided
if (
- (inputMin !== null && inputMin < min) ||
- (inputMax !== null && inputMax > max)
+ (inputMin !== null && min !== undefined && inputMin < min) ||
+ (inputMax !== null && max !== undefined && inputMax > max)
) {
return {
isValid: false,
@@ -249,9 +257,19 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
isOverflowingFilterBar,
} = props;
+ const manualBounds = isSemanticRange(formData);
const [row] = data;
- // @ts-expect-error
- const { min, max }: { min: number; max: number } = row;
+ const min =
+ !manualBounds && typeof row?.min === 'number' ? row.min : undefined;
+ const max =
+ !manualBounds && typeof row?.max === 'number' ? row.max : undefined;
+ // Null aggregates (empty or all-null column) leave the range unbounded;
+ // only a present, non-numeric aggregate marks the column unusable.
+ const isNonNumericBound = (value: unknown) =>
+ value !== null && value !== undefined && typeof value !== 'number';
+ const nonNumericColumn =
+ !manualBounds &&
+ (isNonNumericBound(row?.min) || isNonNumericBound(row?.max));
const sliderStep = useMemo(
() =>
@@ -263,7 +281,9 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
// Get the display mode from formData
const rangeDisplayMode =
- formData?.rangeDisplayMode || RangeDisplayMode.SliderAndInput;
+ manualBounds || min === undefined || max === undefined
+ ? RangeDisplayMode.Input
+ : formData?.rangeDisplayMode || RangeDisplayMode.SliderAndInput;
const minIndex = 0;
const maxIndex = 1;
@@ -279,6 +299,7 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
// Prepare slider value from input value, converting nulls to min/max
const sliderValue = useMemo(() => {
+ if (min === undefined || max === undefined) return [];
const [inputMin, inputMax] = inputValue;
// For single value filters
@@ -320,7 +341,11 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
(value: RangeValue) => {
const [inputMin, inputMax] = value;
setDataMask({
- extraFormData: getRangeExtraFormData(col, inputMin, inputMax),
+ extraFormData: getRangeExtraFormData(
+ col,
+ inputMin,
+ enableSingleExactValue ? inputMin : inputMax,
+ ),
filterState: {
value: enableSingleExactValue
? [inputMin, inputMin]
@@ -331,11 +356,32 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
},
});
},
- [setDataMask],
+ [setDataMask, col, enableSingleExactValue],
);
useEffect(() => {
- if (row?.min === undefined && row?.max === undefined) {
+ if (nonNumericColumn) {
+ return;
+ }
+
+ if (manualBounds) {
+ // Invalid edits keep their draft in the inputs; external saved values
+ // and Clear All are validated without requiring a discovered domain.
+ const value: RangeValue =
+ filterState.validateStatus === 'error'
+ ? inputValue
+ : (filterState.value ?? [null, null]);
+ setInputValue(value);
+ const { isValid, errorMessage } = validateRange(
+ value,
+ undefined,
+ undefined,
+ enableEmptyFilter,
+ enableSingleValue,
+ );
+ setError(errorMessage);
+ if (isValid) updateDataMaskValue(value);
+ else updateDataMaskError(errorMessage);
return;
}
@@ -386,7 +432,13 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
setInputValue(filterState.value);
updateDataMaskValue(filterState.value);
}
- }, [JSON.stringify(filterState.value)]);
+ }, [
+ JSON.stringify(filterState.value),
+ manualBounds,
+ enableEmptyFilter,
+ enableSingleValue,
+ col,
+ ]);
// Get just the filter behavior text without the range information (which is shown in the tooltip)
const metadataText = useMemo(() => {
@@ -410,14 +462,19 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
const handleChange = useCallback(
(newValue: number | null, index: 0 | 1) => {
- if (row?.min === undefined && row?.max === undefined) {
+ if (nonNumericColumn) {
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 (
+ min !== undefined &&
+ max !== undefined &&
+ newValue !== null &&
+ inputValue[index] === null
+ ) {
if (keyPressed.current) {
adjustedValue = newValue;
keyPressed.current = false;
@@ -459,6 +516,8 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
[
min,
max,
+ manualBounds,
+ nonNumericColumn,
enableEmptyFilter,
enableSingleValue,
updateDataMaskError,
@@ -470,6 +529,7 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
// Handler for slider change
const handleSliderChange = useCallback(
(value: number | number[]) => {
+ if (min === undefined || max === undefined) return;
let newInputValue: RangeValue;
if (enableSingleValue !== undefined) {
@@ -502,10 +562,13 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
);
const getMessageAndStatus = useCallback(() => {
- const defaultMessage = t('Choose numbers between %(min)s and %(max)s', {
- min,
- max,
- });
+ const defaultMessage =
+ manualBounds || min === undefined || max === undefined
+ ? t('Leave a bound empty for an unbounded range.')
+ : t('Choose numbers between %(min)s and %(max)s', {
+ min,
+ max,
+ });
if (error) {
return { message: error, status: 'error' as const };
@@ -516,7 +579,7 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
}
return { message: defaultMessage, status: 'help' as const };
- }, [error, min, max, enableSingleValue, metadataText]);
+ }, [error, min, max, manualBounds, enableSingleValue, metadataText]);
const MessageDisplay = useCallback(() => {
const { message, status } = getMessageAndStatus();
@@ -542,7 +605,11 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
);
}, [getMessageAndStatus]);
+ const previousSingleValue = useRef(enableSingleValue);
useEffect(() => {
+ if (manualBounds && previousSingleValue.current === enableSingleValue)
+ return;
+ previousSingleValue.current = enableSingleValue;
if (enableSingleValue !== undefined) {
switch (enableSingleValue) {
case SingleValueType.Minimum:
@@ -572,6 +639,7 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
}, [enableSingleValue]);
const renderSlider = () => {
+ if (min === undefined || max === undefined) return null;
if (enableSingleValue !== undefined) {
return (
<SliderWrapper>
@@ -605,6 +673,37 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
);
};
+ const renderInput = (
+ index: 0 | 1,
+ label: string,
+ bound: number | undefined,
+ ) => {
+ const input = (
+ <InputNumber
+ value={inputValue[index]}
+ onChange={val => handleChange(val, index)}
+ onKeyDown={handleKeyDown}
+ aria-label={manualBounds ? label : undefined}
+ placeholder={manualBounds || bound === undefined ? '' : `${bound}`}
+ style={{ width: '100%' }}
+ status={filterState.validateStatus}
+ data-test={
+ index === minIndex
+ ? 'range-filter-from-input'
+ : 'range-filter-to-input'
+ }
+ />
+ );
+ return manualBounds ? (
+ <label>
+ {label}
+ {input}
+ </label>
+ ) : (
+ input
+ );
+ };
+
const renderInputs = () => (
<Wrapper
tabIndex={-1}
@@ -618,38 +717,24 @@ export default function RangeFilterPlugin(props: PluginFilterRangeProps) {
{/* 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 === SingleValueType.Exact) &&
+ renderInput(
+ minIndex,
+ enableSingleExactValue ? t('Value') : t('Minimum value'),
+ min,
+ )}
{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}
- />
- )}
+ enableSingleValue === SingleValueType.Maximum) &&
+ renderInput(maxIndex, t('Maximum value'), max)}
</Wrapper>
);
return (
<FilterPluginStyle height={height} width={width}>
- {Number.isNaN(Number(min)) || Number.isNaN(Number(max)) ? (
+ {nonNumericColumn ? (
<h4>{t('Chosen non-numeric column')}</h4>
) : (
<FormItem aria-labelledby={`filter-name-${formData.nativeFilterId}`}>
diff --git a/superset-frontend/src/filters/components/Range/buildQuery.test.ts b/superset-frontend/src/filters/components/Range/buildQuery.test.ts
new file mode 100644
index 00000000000..83bd975646d
--- /dev/null
+++ b/superset-frontend/src/filters/components/Range/buildQuery.test.ts
@@ -0,0 +1,50 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { DatasourceType } from '@superset-ui/core';
+import buildQuery from './buildQuery';
+
+test('semantic ranges issue no bounds query, preserving source identity', () => {
+ const context = buildQuery({
+ datasource: '3__semantic_view',
+ viz_type: 'filter_range',
+ groupby: ['Orders.usersAge'],
+ semantic_selection_version: 'cube-member-id-v1',
+ });
+ expect(context.queries).toEqual([]);
+ expect(context.datasource).toEqual({
+ id: 3,
+ type: DatasourceType.SemanticView,
+ });
+ expect(context.form_data?.semantic_selection_version).toBe(
+ 'cube-member-id-v1',
+ );
+});
+
+test('SQL dataset ranges retain their MIN/MAX discovery query', () => {
+ const context = buildQuery({
+ datasource: '3__table',
+ viz_type: 'filter_range',
+ groupby: ['age'],
+ });
+ expect(context.queries).toHaveLength(1);
+ expect(context.queries[0].metrics).toEqual([
+ expect.objectContaining({ aggregate: 'MIN', label: 'min' }),
+ expect.objectContaining({ aggregate: 'MAX', label: 'max' }),
+ ]);
+});
diff --git a/superset-frontend/src/filters/components/Range/buildQuery.ts b/superset-frontend/src/filters/components/Range/buildQuery.ts
index 1ba7aa9da7c..8fcfd1be05d 100644
--- a/superset-frontend/src/filters/components/Range/buildQuery.ts
+++ b/superset-frontend/src/filters/components/Range/buildQuery.ts
@@ -18,6 +18,7 @@
*/
import { buildQueryContext, QueryFormData } from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
+import isSemanticRange from './isSemanticRange';
/**
* The buildQuery function is used to create an instance of QueryContext that's
@@ -34,6 +35,9 @@ import { GenericDataType } from '@apache-superset/core/common';
* if a viz needs multiple different result sets.
*/
export default function buildQuery(formData: QueryFormData) {
+ if (isSemanticRange(formData)) {
+ return buildQueryContext(formData, () => []);
+ }
const { groupby } = formData;
const [column = ''] = groupby || [];
// @ts-expect-error (need update interface Column )
diff --git a/superset-frontend/src/filters/components/Range/index.ts b/superset-frontend/src/filters/components/Range/index.ts
index 2733a35e9e1..a6aa3d9fd42 100644
--- a/superset-frontend/src/filters/components/Range/index.ts
+++ b/superset-frontend/src/filters/components/Range/index.ts
@@ -29,6 +29,8 @@ export default class RangeFilterPlugin extends ChartPlugin {
name: t('Range filter'),
description: t('Range filter plugin using AntD'),
behaviors: [Behavior.InteractiveChart, Behavior.NativeFilter],
+ // Manual bounds remain usable without a query result.
+ enableNoResults: false,
supportsCascadeDependencies: true,
tags: [t('Experimental')],
thumbnail,
diff --git a/superset-frontend/src/filters/components/Range/isSemanticRange.ts b/superset-frontend/src/filters/components/Range/isSemanticRange.ts
new file mode 100644
index 00000000000..6d0fb1dce08
--- /dev/null
+++ b/superset-frontend/src/filters/components/Range/isSemanticRange.ts
@@ -0,0 +1,32 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { DatasourceKey, DatasourceType } from '@superset-ui/core';
+
+/** Identify numeric filters whose source cannot provide aggregate bounds. */
+export default function isSemanticRange(formData: {
+ datasource?: string;
+ viz_type?: string;
+ vizType?: string;
+}): boolean {
+ return (
+ (formData.viz_type ?? formData.vizType) === 'filter_range' &&
+ new DatasourceKey(formData.datasource ?? '').type ===
+ DatasourceType.SemanticView
+ );
+}
diff --git a/superset-frontend/src/filters/components/Range/transformProps.ts b/superset-frontend/src/filters/components/Range/transformProps.ts
index cbca7a982a3..d09f8ce5015 100644
--- a/superset-frontend/src/filters/components/Range/transformProps.ts
+++ b/superset-frontend/src/filters/components/Range/transformProps.ts
@@ -39,7 +39,7 @@ export default function transformProps(chartProps: ChartProps) {
unsetHoveredFilter = noOp,
setFilterActive = noOp,
} = hooks;
- const { data } = queriesData[0];
+ const data = queriesData[0]?.data ?? [];
return {
data,
diff --git a/superset-frontend/src/utils/semanticViewStructure.test.ts b/superset-frontend/src/utils/semanticViewStructure.test.ts
index 356bc606446..fa63550ce63 100644
--- a/superset-frontend/src/utils/semanticViewStructure.test.ts
+++ b/superset-frontend/src/utils/semanticViewStructure.test.ts
@@ -17,6 +17,7 @@
* under the License.
*/
import fetchMock from 'fetch-mock';
+import { GenericDataType } from '@apache-superset/core/common';
import { supersetGetCache } from 'src/utils/cachedSupersetGet';
import {
fetchSemanticViewStructure,
@@ -92,3 +93,52 @@ test.each([undefined, null])(
expect(structure.semantic_selection_version).toBeUndefined();
},
);
+
+test.each([
+ 'bigint',
+ 'integer',
+ 'smallint',
+ 'tinyint',
+ 'numeric',
+ 'numeric(10,2)',
+ 'real',
+ 'BIGINT',
+ 'INTEGER',
+])('preserves SQL numeric dimension %s in the shared column metadata', type => {
+ expect(semanticViewDimensionsToColumns([{ name: 'amount', type }])).toEqual([
+ expect.objectContaining({
+ column_name: 'amount',
+ type_generic: GenericDataType.Numeric,
+ }),
+ ]);
+});
+
+test.each([
+ 'int8',
+ 'int16',
+ 'int32',
+ 'int64',
+ 'uint8',
+ 'uint16',
+ 'uint32',
+ 'uint64',
+ 'halffloat',
+ 'float',
+ 'double',
+ 'decimal32(7, 2)',
+ 'decimal64(7, 2)',
+ 'decimal128(10, 2)',
+ 'decimal256(40, 2)',
+])(
+ 'preserves Arrow numeric dimension %s in the shared column metadata',
+ type => {
+ expect(semanticViewDimensionsToColumns([{ name: 'amount', type }])).toEqual(
+ [
+ expect.objectContaining({
+ column_name: 'amount',
+ type_generic: GenericDataType.Numeric,
+ }),
+ ],
+ );
+ },
+);
diff --git a/superset-frontend/src/utils/semanticViewStructure.ts b/superset-frontend/src/utils/semanticViewStructure.ts
index 3b57d68d1ff..907704fa2b7 100644
--- a/superset-frontend/src/utils/semanticViewStructure.ts
+++ b/superset-frontend/src/utils/semanticViewStructure.ts
@@ -53,7 +53,7 @@ export const mapSemanticTypeToGenericDataType = (
}
if (
- /(\b(u?int\d*)\b|\bfloat\d*\b|\bdouble\b|\bdecimal\d*\b|\bnumber\b)/.test(
+ /(\b(u?int\d*|integer|bigint|smallint|tinyint|numeric|real|halffloat)\b|\bfloat\d*\b|\bdouble\b|\bdecimal\d*\b|\bnumber\b)/.test(
normalized,
)
) {
diff --git a/superset/translations/messages.pot b/superset/translations/messages.pot
index dfe1d3fc22d..d004f070dee 100644
--- a/superset/translations/messages.pot
+++ b/superset/translations/messages.pot
@@ -8199,6 +8199,9 @@ msgstr ""
msgid "Least recently modified"
msgstr ""
+msgid "Leave a bound empty for an unbounded range."
+msgstr ""
+
msgid "Leave blank to keep the current password"
msgstr ""