= ({
className,
diff --git a/superset-frontend/src/explore/components/controls/MatrixifyDimensionControl.tsx b/superset-frontend/src/explore/components/controls/MatrixifyDimensionControl.tsx
index 3832b84d871..d1417b0f020 100644
--- a/superset-frontend/src/explore/components/controls/MatrixifyDimensionControl.tsx
+++ b/superset-frontend/src/explore/components/controls/MatrixifyDimensionControl.tsx
@@ -32,6 +32,7 @@ export interface MatrixifyDimensionControlValue {
dimension: string;
values: any[];
topNValues?: TopNValue[]; // Store topN values with their metric values
+ totalValueCount?: number; // Total number of distinct values for this dimension
}
interface MatrixifyDimensionControlProps {
@@ -42,10 +43,11 @@ interface MatrixifyDimensionControlProps {
description?: string;
hovered?: boolean;
renderTrigger?: boolean;
- selectionMode?: 'members' | 'topn';
+ selectionMode?: 'members' | 'topn' | 'all';
topNMetric?: string;
topNValue?: number;
topNOrder?: 'ASC' | 'DESC';
+ allSortBy?: 'a_to_z' | 'z_to_a' | 'metric';
formData?: any; // For access to filters and time range
validationErrors?: string[];
}
@@ -64,6 +66,7 @@ export default function MatrixifyDimensionControl(
topNMetric,
topNValue,
topNOrder = 'DESC',
+ allSortBy = 'a_to_z',
formData,
validationErrors,
} = props;
@@ -109,15 +112,19 @@ export default function MatrixifyDimensionControl(
}
}, [datasource]);
- // Load dimension values when dimension changes
+ // Load dimension values when dimension changes (members mode, or all mode with A-Z/Z-A sort)
+ const isAllWithMetric = selectionMode === 'all' && allSortBy === 'metric';
useEffect(() => {
if (
!value?.dimension ||
!datasource ||
!datasource.id ||
- selectionMode !== 'members'
+ (selectionMode !== 'members' && selectionMode !== 'all') ||
+ isAllWithMetric
) {
- setValueOptions([]);
+ if (selectionMode !== 'members' && !isAllWithMetric) {
+ setValueOptions([]);
+ }
return undefined;
}
@@ -142,13 +149,43 @@ export default function MatrixifyDimensionControl(
signal,
endpoint,
});
- const values = json.result || [];
+ let values = json.result || [];
+
+ // Sort alphabetically for 'all' mode
+ if (selectionMode === 'all') {
+ const descending = allSortBy === 'z_to_a';
+ values = [...values].sort((a: any, b: any) => {
+ const strA = String(a).toLowerCase();
+ const strB = String(b).toLowerCase();
+ if (strA < strB) return descending ? 1 : -1;
+ if (strA > strB) return descending ? -1 : 1;
+ return 0;
+ });
+ }
+
setValueOptions(
values.map((v: any) => ({
label: optionLabel(v),
value: v,
})),
);
+
+ if (!signal.aborted) {
+ const MAX_ALL_DIMENSION_VALUES = 25;
+ const allValues =
+ selectionMode === 'all'
+ ? values.slice(0, MAX_ALL_DIMENSION_VALUES)
+ : value.values || [];
+ const updatedValue: MatrixifyDimensionControlValue = {
+ dimension: value.dimension,
+ values: allValues,
+ totalValueCount: values.length,
+ };
+ if (value.topNValues) {
+ updatedValue.topNValues = value.topNValues;
+ }
+ onChange(updatedValue);
+ }
} catch (error) {
setValueOptions([]);
} finally {
@@ -161,7 +198,7 @@ export default function MatrixifyDimensionControl(
return () => {
controller.abort();
};
- }, [value?.dimension, datasource, selectionMode]);
+ }, [value?.dimension, datasource, selectionMode, allSortBy]);
// Convert topNValue to number for consistent comparison
const topNValueNum = useMemo(() => {
@@ -176,17 +213,27 @@ export default function MatrixifyDimensionControl(
return typeof topNValue === 'number' ? topNValue : null;
}, [topNValue]);
- // Load TopN values when in TopN mode
+ // Load TopN values when in TopN mode, or All + Metric sort
useEffect(() => {
- if (!value?.dimension || !datasource || selectionMode !== 'topn') {
+ const isTopN = selectionMode === 'topn';
+ const isAllMetric = selectionMode === 'all' && allSortBy === 'metric';
+
+ if (!value?.dimension || !datasource || (!isTopN && !isAllMetric)) {
return undefined;
}
- // If we don't have the required topN parameters, just return without loading
- if (!topNMetric || !topNValueNum || topNValueNum <= 0) {
+ if (!topNMetric) {
return undefined;
}
+ // For topn mode, also require a valid limit
+ if (isTopN && (!topNValueNum || topNValueNum <= 0)) {
+ return undefined;
+ }
+
+ const MAX_ALL_DIMENSION_VALUES = 25;
+ const limit = isAllMetric ? MAX_ALL_DIMENSION_VALUES : topNValueNum!;
+
const controller = new AbortController();
const { signal } = controller;
@@ -199,14 +246,13 @@ export default function MatrixifyDimensionControl(
datasource: datasourceId,
column: value.dimension,
metric: topNMetric,
- limit: topNValueNum,
+ limit,
sortAscending: topNOrder === 'ASC',
filters: formData?.adhoc_filters || [],
timeRange: formData?.time_range,
});
if (!signal.aborted) {
- // Always update with the new topN values
const dimensionValues = extractDimensionValues(values);
onChange({
dimension: value.dimension,
@@ -217,7 +263,6 @@ export default function MatrixifyDimensionControl(
} catch (error: any) {
if (!signal.aborted) {
setTopNError(error.message || t('Failed to load top values'));
- // Clear values on error
onChange({
dimension: value.dimension,
values: [],
@@ -236,8 +281,9 @@ export default function MatrixifyDimensionControl(
value?.dimension,
datasource,
selectionMode,
+ allSortBy,
topNMetric,
- topNValueNum, // Use the converted/validated number
+ topNValueNum,
topNOrder,
formData?.adhoc_filters,
formData?.time_range,
diff --git a/superset-frontend/src/explore/components/controls/SwitchControl.tsx b/superset-frontend/src/explore/components/controls/SwitchControl.tsx
new file mode 100644
index 00000000000..7823dac011d
--- /dev/null
+++ b/superset-frontend/src/explore/components/controls/SwitchControl.tsx
@@ -0,0 +1,65 @@
+/**
+ * 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 { type ReactNode } from 'react';
+import { css } from '@apache-superset/core/theme';
+import { Switch } from '@superset-ui/core/components';
+import ControlHeader from '../ControlHeader';
+
+interface SwitchControlProps {
+ value?: boolean;
+ label?: ReactNode;
+ description?: ReactNode;
+ hovered?: boolean;
+ onChange?: (value: boolean) => void;
+ validationErrors?: string[];
+}
+
+export default function SwitchControl({
+ value = false,
+ onChange,
+ ...props
+}: SwitchControlProps) {
+ const handleChange = (checked: boolean) => {
+ onChange?.(checked);
+ };
+
+ const switchNode = (
+
+ );
+
+ if (props.label) {
+ return (
+
+ handleChange(!value)}
+ />
+
+ );
+ }
+ return switchNode;
+}
diff --git a/superset-frontend/src/explore/components/controls/VerticalRadioControl.tsx b/superset-frontend/src/explore/components/controls/VerticalRadioControl.tsx
new file mode 100644
index 00000000000..ee382d7faf9
--- /dev/null
+++ b/superset-frontend/src/explore/components/controls/VerticalRadioControl.tsx
@@ -0,0 +1,104 @@
+/**
+ * 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 { type ReactNode } from 'react';
+import { css, useTheme } from '@apache-superset/core/theme';
+import { JsonValue } from '@superset-ui/core';
+import { Radio } from '@superset-ui/core/components/Radio';
+import { Space } from '@superset-ui/core/components/Space';
+import { Tooltip } from '@superset-ui/core/components/Tooltip';
+import { Icons } from '@superset-ui/core/components/Icons';
+import ControlHeader from '../ControlHeader';
+
+interface RadioOption {
+ value: JsonValue;
+ label: ReactNode;
+ disabled?: boolean;
+ tooltip?: string;
+}
+
+type RadioOptionTuple = [JsonValue, ReactNode];
+
+interface VerticalRadioControlProps {
+ value?: JsonValue;
+ label?: ReactNode;
+ description?: ReactNode;
+ hovered?: boolean;
+ options: (RadioOption | RadioOptionTuple)[];
+ onChange: (value: JsonValue) => void;
+ validationErrors?: string[];
+}
+
+function normalizeOption(option: RadioOption | RadioOptionTuple): RadioOption {
+ if (Array.isArray(option)) {
+ return { value: option[0], label: option[1] };
+ }
+ return option;
+}
+
+export default function VerticalRadioControl({
+ value: initialValue,
+ options,
+ onChange,
+ ...props
+}: VerticalRadioControlProps) {
+ const theme = useTheme();
+ const normalizedOptions = options.map(normalizeOption);
+ const currentValue = initialValue ?? normalizedOptions[0]?.value;
+
+ return (
+
+
+ onChange(e.target.value)}
+ >
+
+ {normalizedOptions.map(
+ ({ value: val, label, disabled = false, tooltip }) => (
+
+ {label}
+ {tooltip && (
+
+
+
+ )}
+
+ ),
+ )}
+
+
+
+ );
+}
diff --git a/superset-frontend/src/explore/components/controls/index.ts b/superset-frontend/src/explore/components/controls/index.ts
index 326da43ce84..1550df945e7 100644
--- a/superset-frontend/src/explore/components/controls/index.ts
+++ b/superset-frontend/src/explore/components/controls/index.ts
@@ -60,6 +60,8 @@ import TimeRangeControl from './TimeRangeControl';
import ColorBreakpointsControl from './ColorBreakpointsControl';
import MatrixifyDimensionControl from './MatrixifyDimensionControl';
import JSEditorControl from './JSEditorControl';
+import SwitchControl from './SwitchControl';
+import VerticalRadioControl from './VerticalRadioControl';
const extensionsRegistry = getExtensionsRegistry();
const DateFilterControlExtension = extensionsRegistry.get(
@@ -109,6 +111,8 @@ const controlMap = {
NumberControl,
TimeRangeControl,
MatrixifyDimensionControl,
+ SwitchControl,
+ VerticalRadioControl,
...sharedControlComponents,
};
export default controlMap;
diff --git a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx
index ed02bd183a7..94eeec9a90b 100644
--- a/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx
+++ b/superset-frontend/src/explore/components/useExploreAdditionalActionsMenu/index.tsx
@@ -172,7 +172,9 @@ interface ExploreSlice {
interface ExploreState {
charts?: Record;
- explore?: ExploreSlice;
+ explore?: ExploreSlice & {
+ chartStates?: Record;
+ };
common?: {
conf?: {
CSV_STREAMING_ROW_THRESHOLD?: number;
@@ -221,6 +223,14 @@ export const useExploreAdditionalActionsMenu = (
state.common?.conf?.CSV_STREAMING_ROW_THRESHOLD ||
DEFAULT_CSV_STREAMING_ROW_THRESHOLD,
);
+ const exploreChartState = useSelector(
+ state => {
+ const chartKey = state.explore ? getChartKey(state.explore) : undefined;
+ return chartKey != null
+ ? state.explore?.chartStates?.[chartKey]
+ : undefined;
+ },
+ );
// Streaming export state and handlers
const [isStreamingModalVisible, setIsStreamingModalVisible] = useState(false);
@@ -274,6 +284,9 @@ export const useExploreAdditionalActionsMenu = (
'EXPORT_CURRENT_VIEW' as Behavior,
);
+ const permalinkChartState = (exploreChartState as { state?: JsonObject })
+ ?.state;
+
const shareByEmail = useCallback(async () => {
try {
const subject = t('Superset Chart');
@@ -282,6 +295,8 @@ export const useExploreAdditionalActionsMenu = (
}
const result = await getChartPermalink(
latestQueryFormData as Pick,
+ undefined,
+ permalinkChartState,
);
if (!result?.url) {
throw new Error('Failed to generate permalink');
@@ -293,7 +308,7 @@ export const useExploreAdditionalActionsMenu = (
} catch (error) {
addDangerToast(t('Sorry, something went wrong. Try again later.'));
}
- }, [addDangerToast, latestQueryFormData]);
+ }, [addDangerToast, latestQueryFormData, permalinkChartState]);
const exportCSV = useCallback(() => {
if (!canDownloadCSV) return null;
@@ -411,6 +426,8 @@ export const useExploreAdditionalActionsMenu = (
await copyTextToClipboard(async () => {
const result = await getChartPermalink(
latestQueryFormData as Pick,
+ undefined,
+ permalinkChartState,
);
if (!result?.url) {
throw new Error('Failed to generate permalink');
@@ -421,7 +438,12 @@ export const useExploreAdditionalActionsMenu = (
} catch (error) {
addDangerToast(t('Sorry, something went wrong. Try again later.'));
}
- }, [addDangerToast, addSuccessToast, latestQueryFormData]);
+ }, [
+ addDangerToast,
+ addSuccessToast,
+ latestQueryFormData,
+ permalinkChartState,
+ ]);
// Minimal client-side CSV builder used for "Current View" when pagination is disabled
const downloadClientCSV = (
diff --git a/superset-frontend/src/explore/controlPanels/sections.tsx b/superset-frontend/src/explore/controlPanels/sections.tsx
index 816d3e18604..bac8cddaf75 100644
--- a/superset-frontend/src/explore/controlPanels/sections.tsx
+++ b/superset-frontend/src/explore/controlPanels/sections.tsx
@@ -277,57 +277,77 @@ export const NVD3TimeSeries: ControlPanelSectionConfig[] = [
function buildMatrixifySection(
axis: 'columns' | 'rows',
): ControlPanelSectionConfig {
- const baseControls = [
- [`matrixify_mode_${axis}`],
- [`matrixify_${axis}`],
- [`matrixify_dimension_selection_mode_${axis}`],
- [`matrixify_dimension_${axis}`],
- [`matrixify_topn_dimension_${axis}`],
- [`matrixify_topn_value_${axis}`],
- [`matrixify_topn_metric_${axis}`],
- [`matrixify_topn_order_${axis}`],
- ];
-
- // Add enable checkbox at the beginning of each section
- const enableControl =
+ const customizationControls =
axis === 'rows'
- ? 'matrixify_enable_vertical_layout'
- : 'matrixify_enable_horizontal_layout';
-
- baseControls.unshift([enableControl]);
-
- // Add specific controls for each axis
- if (axis === 'rows') {
- // Add show row labels after enable
- baseControls.splice(1, 0, ['matrixify_show_row_labels']);
- } else if (axis === 'columns') {
- // Add show column headers after enable
- baseControls.splice(1, 0, ['matrixify_show_column_headers']);
- }
+ ? ['matrixify_show_row_labels', 'matrixify_row_height']
+ : ['matrixify_show_column_headers', 'matrixify_fit_columns_dynamically'];
return {
label:
axis === 'columns'
- ? t('Horizontal layout (columns)')
- : t('Vertical layout (rows)'),
+ ? t('Columns (horizontal layout)')
+ : t('Rows (vertical layout)'),
expanded: true,
tabOverride: 'matrixify',
- controlSetRows: baseControls,
+ visibility: ({ controls }) => controls?.matrixify_enable?.value === true,
+ controlSetRows: [
+ [`matrixify_mode_${axis}`],
+ [`matrixify_${axis}`],
+ [`matrixify_dimension_selection_mode_${axis}`],
+ [`matrixify_dimension_${axis}`],
+ [`matrixify_topn_dimension_${axis}`],
+ [`matrixify_topn_value_${axis}`],
+ [`matrixify_all_sort_by_${axis}`],
+ [`matrixify_topn_metric_${axis}`],
+ [`matrixify_topn_order_${axis}`],
+ [
+
+ {t('Customization and styling')}
+ ,
+ ],
+ customizationControls,
+ ],
};
}
export const matrixifyRows = buildMatrixifySection('rows');
export const matrixifyColumns = buildMatrixifySection('columns');
+export const matrixifyEnableSection: ControlPanelSectionConfig = {
+ label: t('Matrixify'),
+ expanded: true,
+ tabOverride: 'matrixify',
+ controlSetRows: [
+ [
+ {
+ name: 'matrixify_enable',
+ config: {
+ type: 'SwitchControl',
+ label: t('Enable matrixify'),
+ default: false,
+ renderTrigger: true,
+ },
+ },
+ ],
+ ],
+};
+
export const matrixifyCells: ControlPanelSectionConfig = {
label: t('Cell layout & styling'),
expanded: true,
tabOverride: 'matrixify',
- visibility: ({ controls }) =>
- controls?.matrixify_enable_vertical_layout?.value === true ||
- controls?.matrixify_enable_horizontal_layout?.value === true,
+ visibility: ({ controls }) => {
+ if (controls?.matrixify_enable?.value !== true) return false;
+ const rowMode = controls?.matrixify_mode_rows?.value;
+ const colMode = controls?.matrixify_mode_columns?.value;
+ return (
+ rowMode === 'metrics' ||
+ rowMode === 'dimensions' ||
+ colMode === 'metrics' ||
+ colMode === 'dimensions'
+ );
+ },
controlSetRows: [
- ['matrixify_row_height', 'matrixify_fit_columns_dynamically'],
['matrixify_charts_per_row'],
['matrixify_cell_title_template'],
],
diff --git a/superset-frontend/src/explore/exploreUtils/exportChart.test.ts b/superset-frontend/src/explore/exploreUtils/exportChart.test.ts
index 661e1248b3e..74c905f347a 100644
--- a/superset-frontend/src/explore/exploreUtils/exportChart.test.ts
+++ b/superset-frontend/src/explore/exploreUtils/exportChart.test.ts
@@ -144,11 +144,9 @@ test('exportChart passes xlsx exportType for Excel exports', async () => {
});
test('exportChart legacy API (useLegacyApi=true) passes prefixed URL with app root configured', async () => {
- // Legacy API uses getExploreUrl() -> getURIDirectory() -> ensureAppRoot()
const appRoot = '/superset';
ensureAppRoot.mockImplementation((path: string) => `${appRoot}${path}`);
- // Configure mock to return useLegacyApi: true
getChartMetadataRegistry.mockReturnValue({
get: jest.fn().mockReturnValue({ useLegacyApi: true, parseMethod: 'json' }),
});
@@ -167,7 +165,105 @@ test('exportChart legacy API (useLegacyApi=true) passes prefixed URL with app ro
expect(onStartStreamingExport).toHaveBeenCalledTimes(1);
const callArgs = onStartStreamingExport.mock.calls[0][0];
- // Legacy path uses getURIDirectory which calls ensureAppRoot
- expect(callArgs.url).toContain(appRoot);
+ expect(callArgs.url).toBe('/superset/superset/explore_json/?csv=true');
expect(callArgs.exportType).toBe('csv');
});
+
+test('exportChart legacy API builds relative URL for CSV export without app root', async () => {
+ ensureAppRoot.mockImplementation((path: string) => path);
+
+ getChartMetadataRegistry.mockReturnValue({
+ get: jest.fn().mockReturnValue({ useLegacyApi: true, parseMethod: 'json' }),
+ });
+
+ const onStartStreamingExport = jest.fn();
+ const legacyFormData = {
+ datasource: '1__table',
+ viz_type: 'world_map',
+ };
+
+ await exportChart({
+ formData: legacyFormData,
+ resultFormat: 'csv',
+ onStartStreamingExport: onStartStreamingExport as unknown as null,
+ });
+
+ expect(onStartStreamingExport).toHaveBeenCalledTimes(1);
+ const callArgs = onStartStreamingExport.mock.calls[0][0];
+ expect(callArgs.url).toBe('/superset/explore_json/?csv=true');
+});
+
+test('exportChart legacy API builds relative URL for xlsx export', async () => {
+ ensureAppRoot.mockImplementation((path: string) => path);
+
+ getChartMetadataRegistry.mockReturnValue({
+ get: jest.fn().mockReturnValue({ useLegacyApi: true, parseMethod: 'json' }),
+ });
+
+ const onStartStreamingExport = jest.fn();
+ const legacyFormData = {
+ datasource: '1__table',
+ viz_type: 'bubble',
+ };
+
+ await exportChart({
+ formData: legacyFormData,
+ resultFormat: 'xlsx',
+ resultType: 'results',
+ onStartStreamingExport: onStartStreamingExport as unknown as null,
+ });
+
+ expect(onStartStreamingExport).toHaveBeenCalledTimes(1);
+ const callArgs = onStartStreamingExport.mock.calls[0][0];
+ expect(callArgs.url).toBe('/superset/explore_json/?xlsx=true');
+});
+
+test('exportChart legacy API calls postForm with relative URL', async () => {
+ const { SupersetClient } = jest.requireMock('@superset-ui/core');
+ ensureAppRoot.mockImplementation((path: string) => path);
+
+ getChartMetadataRegistry.mockReturnValue({
+ get: jest.fn().mockReturnValue({ useLegacyApi: true, parseMethod: 'json' }),
+ });
+
+ const legacyFormData = {
+ datasource: '1__table',
+ viz_type: 'world_map',
+ };
+
+ await exportChart({
+ formData: legacyFormData,
+ resultFormat: 'csv',
+ resultType: 'full',
+ });
+
+ expect(SupersetClient.postForm).toHaveBeenCalledTimes(1);
+ const [url] = SupersetClient.postForm.mock.calls[0];
+ expect(url).toBe('/superset/explore_json/?csv=true');
+ expect(url).not.toMatch(/^https?:\/\//);
+});
+
+test('exportChart legacy API includes force param when force=true', async () => {
+ ensureAppRoot.mockImplementation((path: string) => path);
+
+ getChartMetadataRegistry.mockReturnValue({
+ get: jest.fn().mockReturnValue({ useLegacyApi: true, parseMethod: 'json' }),
+ });
+
+ const onStartStreamingExport = jest.fn();
+ const legacyFormData = {
+ datasource: '1__table',
+ viz_type: 'world_map',
+ };
+
+ await exportChart({
+ formData: legacyFormData,
+ resultFormat: 'csv',
+ force: true,
+ onStartStreamingExport: onStartStreamingExport as unknown as null,
+ });
+
+ expect(onStartStreamingExport).toHaveBeenCalledTimes(1);
+ const callArgs = onStartStreamingExport.mock.calls[0][0];
+ expect(callArgs.url).toBe('/superset/explore_json/?force=true&csv=true');
+});
diff --git a/superset-frontend/src/explore/exploreUtils/getExploreUrl.test.ts b/superset-frontend/src/explore/exploreUtils/getExploreUrl.test.ts
index d1409b99c16..032133545e8 100644
--- a/superset-frontend/src/explore/exploreUtils/getExploreUrl.test.ts
+++ b/superset-frontend/src/explore/exploreUtils/getExploreUrl.test.ts
@@ -49,3 +49,34 @@ test('Get ExploreUrl with endpointType:full and method:GET', () => {
getExploreUrl({ ...params, endpointType: 'full', method: 'GET' }),
).toBe('http://localhost/superset/explore_json/');
});
+
+test('Get relative ExploreUrl with endpointType:csv', () => {
+ const params = createParams();
+ expect(
+ getExploreUrl({ ...params, endpointType: 'csv', relative: true }),
+ ).toBe('/superset/explore_json/?csv=true');
+});
+
+test('Get relative ExploreUrl with endpointType:xlsx', () => {
+ const params = createParams();
+ expect(
+ getExploreUrl({ ...params, endpointType: 'xlsx', relative: true }),
+ ).toBe('/superset/explore_json/?xlsx=true');
+});
+
+test('Get relative ExploreUrl with force:true', () => {
+ const params = createParams();
+ expect(
+ getExploreUrl({
+ ...params,
+ endpointType: 'csv',
+ force: true,
+ relative: true,
+ }),
+ ).toBe('/superset/explore_json/?force=true&csv=true');
+});
+
+test('Get relative ExploreUrl with endpointType:base', () => {
+ const params = createParams();
+ expect(getExploreUrl({ ...params, relative: true })).toBe('/explore/');
+});
diff --git a/superset-frontend/src/explore/exploreUtils/getLegacyEndpointType.test.ts b/superset-frontend/src/explore/exploreUtils/getLegacyEndpointType.test.ts
index 31655fec5cf..eca145a680d 100644
--- a/superset-frontend/src/explore/exploreUtils/getLegacyEndpointType.test.ts
+++ b/superset-frontend/src/explore/exploreUtils/getLegacyEndpointType.test.ts
@@ -32,3 +32,9 @@ test('Should return resultFormat when resultFormat:csv', () => {
getLegacyEndpointType({ ...createParams(), resultFormat: 'csv' }),
).toBe('csv');
});
+
+test('Should return resultFormat when resultFormat:xlsx', () => {
+ expect(
+ getLegacyEndpointType({ ...createParams(), resultFormat: 'xlsx' }),
+ ).toBe('xlsx');
+});
diff --git a/superset-frontend/src/explore/exploreUtils/index.ts b/superset-frontend/src/explore/exploreUtils/index.ts
index 1d4c0e3758b..d67e5f8474b 100644
--- a/superset-frontend/src/explore/exploreUtils/index.ts
+++ b/superset-frontend/src/explore/exploreUtils/index.ts
@@ -75,6 +75,7 @@ interface GetExploreUrlParams {
requestParams?: Record;
allowDomainSharding?: boolean;
method?: 'GET' | 'POST';
+ relative?: boolean;
}
interface BuildV1ChartDataPayloadParams {
@@ -153,9 +154,15 @@ export function getURIDirectory(
includeAppRoot = true,
): string {
// Building the directory part of the URI
- const uri = ['full', 'json', 'csv', 'query', 'results', 'samples'].includes(
- endpointType,
- )
+ const uri = [
+ 'full',
+ 'json',
+ 'csv',
+ 'xlsx',
+ 'query',
+ 'results',
+ 'samples',
+ ].includes(endpointType)
? '/superset/explore_json/'
: '/explore/';
return includeAppRoot ? ensureAppRoot(uri) : uri;
@@ -215,6 +222,7 @@ export function getExploreUrl({
requestParams = {},
allowDomainSharding = false,
method = 'POST',
+ relative = false,
}: GetExploreUrlParams): string | null {
if (!formData.datasource) {
return null;
@@ -224,10 +232,12 @@ export function getExploreUrl({
// eslint-disable-next-line no-param-reassign
delete formData.label_colors;
- let uri = getChartDataUri({
- path: '/',
- allowDomainSharding,
- });
+ let uri = relative
+ ? new URI('/')
+ : getChartDataUri({
+ path: '/',
+ allowDomainSharding,
+ });
if (curUrl) {
uri = URI(URI(curUrl).search());
}
@@ -256,6 +266,9 @@ export function getExploreUrl({
if (endpointType === 'csv') {
search.csv = 'true';
}
+ if (endpointType === 'xlsx') {
+ search.xlsx = 'true';
+ }
if (endpointType === URL_PARAMS.standalone.name) {
search.standalone = '1';
}
@@ -335,7 +348,8 @@ export const getLegacyEndpointType = ({
}: {
resultType: string;
resultFormat: string;
-}): string => (resultFormat === 'csv' ? resultFormat : resultType);
+}): string =>
+ resultFormat === 'csv' || resultFormat === 'xlsx' ? resultFormat : resultType;
export const exportChart = async ({
formData,
@@ -353,7 +367,9 @@ export const exportChart = async ({
url = getExploreUrl({
formData,
endpointType,
+ force,
allowDomainSharding: false,
+ relative: true,
});
payload = formData;
} else {
diff --git a/superset-frontend/src/explore/reducers/exploreReducer.ts b/superset-frontend/src/explore/reducers/exploreReducer.ts
index e940699e32c..faffbfac5fa 100644
--- a/superset-frontend/src/explore/reducers/exploreReducer.ts
+++ b/superset-frontend/src/explore/reducers/exploreReducer.ts
@@ -17,7 +17,12 @@
* under the License.
*/
/* eslint camelcase: 0 */
-import { ensureIsArray, QueryFormData, JsonValue } from '@superset-ui/core';
+import {
+ ensureIsArray,
+ QueryFormData,
+ JsonValue,
+ JsonObject,
+} from '@superset-ui/core';
import {
ControlState,
ControlStateMapping,
@@ -66,6 +71,7 @@ export interface ExploreState {
owners?: string[] | null;
};
saveAction?: SaveActionType | null;
+ chartStates?: Record;
}
// Action type definitions
@@ -165,6 +171,13 @@ interface SetForceQueryAction {
force: boolean;
}
+interface UpdateExploreChartStateAction {
+ type: typeof actions.UPDATE_EXPLORE_CHART_STATE;
+ chartId: number;
+ chartState: Record;
+ lastModified: number;
+}
+
type ExploreAction =
| DynamicPluginControlsReadyAction
| ToggleFaveStarAction
@@ -183,6 +196,7 @@ type ExploreAction =
| SetStashFormDataAction
| SliceUpdatedAction
| SetForceQueryAction
+ | UpdateExploreChartStateAction
| HydrateExplore;
// Extended control state for dynamic form controls - uses Record for flexibility
@@ -621,10 +635,25 @@ export default function exploreReducer(
force: typedAction.force,
};
},
+ [actions.UPDATE_EXPLORE_CHART_STATE]() {
+ const typedAction = action as UpdateExploreChartStateAction;
+ return {
+ ...state,
+ chartStates: {
+ ...state.chartStates,
+ [typedAction.chartId]: {
+ chartId: typedAction.chartId,
+ state: typedAction.chartState,
+ lastModified: typedAction.lastModified,
+ },
+ },
+ };
+ },
[HYDRATE_EXPLORE]() {
const typedAction = action as HydrateExplore;
+ const exploreData = typedAction.data.explore;
return {
- ...typedAction.data.explore,
+ ...exploreData,
} as ExploreState;
},
};
diff --git a/superset-frontend/src/explore/types.ts b/superset-frontend/src/explore/types.ts
index a032de3dd76..d1420fbb3fd 100644
--- a/superset-frontend/src/explore/types.ts
+++ b/superset-frontend/src/explore/types.ts
@@ -98,7 +98,10 @@ export interface ExplorePageInitialData {
}
export interface ExploreResponsePayload {
- result: ExplorePageInitialData & { message: string };
+ result: ExplorePageInitialData & {
+ message: string;
+ chartState?: JsonObject;
+ };
}
export interface ExplorePageState {
diff --git a/superset-frontend/src/filters/components/Select/SelectFilterPlugin.test.tsx b/superset-frontend/src/filters/components/Select/SelectFilterPlugin.test.tsx
index 310d6fa5b9f..e0f7824b2be 100644
--- a/superset-frontend/src/filters/components/Select/SelectFilterPlugin.test.tsx
+++ b/superset-frontend/src/filters/components/Select/SelectFilterPlugin.test.tsx
@@ -16,8 +16,11 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { AppSection } from '@superset-ui/core';
+import { AppSection, Behavior, ChartProps } from '@superset-ui/core';
+import { supersetTheme } from '@apache-superset/core/theme';
import {
+ act,
+ fireEvent,
render,
screen,
userEvent,
@@ -26,6 +29,13 @@ import {
import { NULL_STRING } from 'src/utils/common';
import SelectFilterPlugin from './SelectFilterPlugin';
import transformProps from './transformProps';
+import { FilterState } from '@superset-ui/core';
+import {
+ SelectFilterOperatorType,
+ PluginFilterSelectChartProps,
+ PluginFilterSelectProps,
+ PluginFilterSelectQueryFormData,
+} from './types';
jest.useFakeTimers();
@@ -69,11 +79,36 @@ const selectMultipleProps = {
rejected_filters: [],
},
],
- behaviors: ['NATIVE_FILTER'],
+ behaviors: [Behavior.NativeFilter],
isRefreshing: false,
appSection: AppSection.Dashboard,
};
+type SelectTestOverrides = {
+ formData?: Partial;
+ filterState?: Partial;
+ setDataMask?: jest.Mock;
+};
+
+const buildSelectFilterProps = (overrides: SelectTestOverrides = {}) => {
+ const chartProps = new ChartProps({
+ ...selectMultipleProps,
+ formData: { ...selectMultipleProps.formData, ...overrides.formData },
+ ...(overrides.filterState !== undefined && {
+ filterState: overrides.filterState,
+ }),
+ theme: supersetTheme,
+ }) as PluginFilterSelectChartProps;
+
+ return {
+ ...transformProps(chartProps),
+ appSection: AppSection.Dashboard,
+ isRefreshing: false,
+ setDataMask: overrides.setDataMask ?? jest.fn(),
+ showOverflow: false,
+ } as PluginFilterSelectProps;
+};
+
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('SelectFilterPlugin', () => {
const setDataMask = jest.fn();
@@ -1249,3 +1284,377 @@ test('resets dependent filter to first item when value does not exist in data',
);
});
});
+
+test('renders text input instead of dropdown when operatorType is ILIKE contains', () => {
+ jest.useFakeTimers();
+ const setDataMaskMock = jest.fn();
+ const props = buildSelectFilterProps({
+ formData: { operatorType: SelectFilterOperatorType.Contains },
+ filterState: { value: undefined },
+ setDataMask: setDataMaskMock,
+ });
+
+ render( , {
+ useRedux: true,
+ initialState: {
+ nativeFilters: {
+ filters: { 'test-filter': { name: 'Test Filter' } },
+ },
+ dataMask: {
+ 'test-filter': {
+ extraFormData: {},
+ filterState: { value: undefined },
+ },
+ },
+ },
+ });
+
+ expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
+ expect(
+ screen.getByPlaceholderText('Type to search (contains)...'),
+ ).toBeInTheDocument();
+});
+
+test('renders text input with starts-with placeholder', () => {
+ jest.useFakeTimers();
+ const setDataMaskMock = jest.fn();
+ const props = buildSelectFilterProps({
+ formData: { operatorType: SelectFilterOperatorType.StartsWith },
+ filterState: { value: undefined },
+ setDataMask: setDataMaskMock,
+ });
+
+ render( , {
+ useRedux: true,
+ initialState: {
+ nativeFilters: {
+ filters: { 'test-filter': { name: 'Test Filter' } },
+ },
+ dataMask: {
+ 'test-filter': {
+ extraFormData: {},
+ filterState: { value: undefined },
+ },
+ },
+ },
+ });
+
+ expect(
+ screen.getByPlaceholderText('Type to search (starts with)...'),
+ ).toBeInTheDocument();
+});
+
+test('typing in LIKE input calls setDataMask with ILIKE Contains payload', async () => {
+ jest.useFakeTimers();
+ const setDataMaskMock = jest.fn();
+ const props = buildSelectFilterProps({
+ formData: { operatorType: SelectFilterOperatorType.Contains },
+ filterState: { value: undefined },
+ setDataMask: setDataMaskMock,
+ });
+
+ render( , {
+ useRedux: true,
+ initialState: {
+ nativeFilters: {
+ filters: { 'test-filter': { name: 'Test Filter' } },
+ },
+ dataMask: {
+ 'test-filter': {
+ extraFormData: {},
+ filterState: { value: undefined },
+ },
+ },
+ },
+ });
+
+ setDataMaskMock.mockClear();
+ const input = screen.getByPlaceholderText('Type to search (contains)...');
+ fireEvent.change(input, { target: { value: 'Jen' } });
+ act(() => {
+ jest.advanceTimersByTime(500);
+ });
+
+ expect(setDataMaskMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ extraFormData: {
+ filters: [
+ {
+ col: 'gender',
+ op: 'ILIKE',
+ val: '%Jen%',
+ },
+ ],
+ },
+ }),
+ );
+});
+
+test('typing in LIKE input with inverse selection calls setDataMask with NOT ILIKE payload', async () => {
+ jest.useFakeTimers();
+ const setDataMaskMock = jest.fn();
+ const props = buildSelectFilterProps({
+ formData: {
+ operatorType: SelectFilterOperatorType.Contains,
+ inverseSelection: true,
+ },
+ filterState: { value: undefined },
+ setDataMask: setDataMaskMock,
+ });
+
+ render( , {
+ useRedux: true,
+ initialState: {
+ nativeFilters: {
+ filters: { 'test-filter': { name: 'Test Filter' } },
+ },
+ dataMask: {
+ 'test-filter': {
+ extraFormData: {},
+ filterState: { value: undefined },
+ },
+ },
+ },
+ });
+
+ setDataMaskMock.mockClear();
+ const input = screen.getByPlaceholderText('Type to search (contains)...');
+ fireEvent.change(input, { target: { value: 'Jen' } });
+ act(() => {
+ jest.advanceTimersByTime(500);
+ });
+
+ expect(setDataMaskMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ extraFormData: {
+ filters: [
+ {
+ col: 'gender',
+ op: 'NOT ILIKE',
+ val: '%Jen%',
+ },
+ ],
+ },
+ }),
+ );
+});
+
+test('clear-all resets LIKE input value and calls setDataMask with empty state', async () => {
+ jest.useFakeTimers();
+ const setDataMaskMock = jest.fn();
+ const likeProps = buildSelectFilterProps({
+ formData: { operatorType: SelectFilterOperatorType.Contains },
+ filterState: { value: ['Jen'] },
+ setDataMask: setDataMaskMock,
+ });
+
+ const reduxState = {
+ useRedux: true,
+ initialState: {
+ nativeFilters: {
+ filters: { 'test-filter': { name: 'Test Filter' } },
+ },
+ dataMask: {
+ 'test-filter': {
+ extraFormData: {
+ filters: [{ col: 'gender', op: 'ILIKE', val: '%Jen%' }],
+ },
+ filterState: { value: ['Jen'] },
+ },
+ },
+ },
+ };
+
+ const { rerender } = render(
+ ,
+ reduxState,
+ );
+
+ const input = screen.getByPlaceholderText('Type to search (contains)...');
+ expect(input).toHaveValue('Jen');
+
+ setDataMaskMock.mockClear();
+
+ rerender(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(input).toHaveValue('');
+ });
+
+ await waitFor(() => {
+ expect(setDataMaskMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ filterState: expect.objectContaining({
+ value: null,
+ }),
+ }),
+ );
+ });
+
+ const callsBeforeDebounceFlush = setDataMaskMock.mock.calls.length;
+
+ act(() => {
+ jest.advanceTimersByTime(500);
+ });
+
+ expect(setDataMaskMock).toHaveBeenCalledTimes(callsBeforeDebounceFlush);
+});
+
+test('pending LIKE debounce still applies after rerender recreates updateDataMask', async () => {
+ jest.useFakeTimers();
+ const setDataMaskMock = jest.fn();
+ const likeProps = buildSelectFilterProps({
+ formData: { operatorType: SelectFilterOperatorType.Contains },
+ filterState: { value: undefined },
+ setDataMask: setDataMaskMock,
+ });
+
+ const reduxState = {
+ useRedux: true,
+ initialState: {
+ nativeFilters: {
+ filters: { 'test-filter': { name: 'Test Filter' } },
+ },
+ dataMask: {
+ 'test-filter': {
+ extraFormData: {},
+ filterState: { value: undefined },
+ },
+ },
+ },
+ };
+
+ const { rerender } = render(
+ ,
+ reduxState,
+ );
+
+ fireEvent.change(
+ screen.getByPlaceholderText('Type to search (contains)...'),
+ {
+ target: { value: 'Jen' },
+ },
+ );
+
+ setDataMaskMock.mockClear();
+
+ rerender(
+ ,
+ );
+
+ act(() => {
+ jest.advanceTimersByTime(500);
+ });
+
+ expect(setDataMaskMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ extraFormData: {
+ filters: [
+ {
+ col: 'gender',
+ op: 'ILIKE',
+ val: '%Jen%',
+ },
+ ],
+ },
+ }),
+ );
+});
+
+test('pending LIKE debounce is canceled when operatorType switches back to Exact', async () => {
+ jest.useFakeTimers();
+ const setDataMaskMock = jest.fn();
+ const likeProps = buildSelectFilterProps({
+ formData: { operatorType: SelectFilterOperatorType.Contains },
+ filterState: { value: undefined },
+ setDataMask: setDataMaskMock,
+ });
+
+ const reduxState = {
+ useRedux: true,
+ initialState: {
+ nativeFilters: {
+ filters: { 'test-filter': { name: 'Test Filter' } },
+ },
+ dataMask: {
+ 'test-filter': {
+ extraFormData: {},
+ filterState: { value: undefined },
+ },
+ },
+ },
+ };
+
+ const { rerender } = render(
+ ,
+ reduxState,
+ );
+
+ fireEvent.change(
+ screen.getByPlaceholderText('Type to search (contains)...'),
+ {
+ target: { value: 'Jen' },
+ },
+ );
+
+ setDataMaskMock.mockClear();
+
+ rerender(
+ ,
+ );
+
+ act(() => {
+ jest.advanceTimersByTime(500);
+ });
+
+ expect(setDataMaskMock).not.toHaveBeenCalled();
+});
+
+test('renders standard Select dropdown when operatorType is Exact', () => {
+ jest.useFakeTimers();
+ const setDataMaskMock = jest.fn();
+ const props = buildSelectFilterProps({
+ formData: { operatorType: SelectFilterOperatorType.Exact },
+ setDataMask: setDataMaskMock,
+ });
+
+ render( , {
+ useRedux: true,
+ initialState: {
+ nativeFilters: {
+ filters: { 'test-filter': { name: 'Test Filter' } },
+ },
+ dataMask: {
+ 'test-filter': {
+ extraFormData: {
+ filters: [{ col: 'gender', op: 'IN', val: ['boy'] }],
+ },
+ filterState: {
+ value: ['boy'],
+ label: 'boy',
+ excludeFilterValues: true,
+ },
+ },
+ },
+ },
+ });
+
+ expect(screen.getAllByRole('combobox').length).toBeGreaterThan(0);
+});
diff --git a/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx b/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx
index 3127b26bbc0..c282b893c35 100644
--- a/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx
+++ b/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx
@@ -39,6 +39,7 @@ import {
Select,
Space,
Constants,
+ Input,
} from '@superset-ui/core/components';
import {
hasOption,
@@ -47,7 +48,11 @@ import {
import { FilterBarOrientation } from 'src/dashboard/types';
import { getDataRecordFormatter, getSelectExtraFormData } from '../../utils';
import { FilterPluginStyle, StatusMessage } from '../common';
-import { PluginFilterSelectProps, SelectValue } from './types';
+import {
+ PluginFilterSelectProps,
+ SelectFilterOperatorType,
+ SelectValue,
+} from './types';
type DataMaskAction =
| { type: 'ownState'; ownState: JsonObject }
@@ -142,6 +147,7 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
inverseSelection,
defaultToFirstItem,
searchAllOptions,
+ operatorType = SelectFilterOperatorType.Exact,
} = formData;
const groupby = useMemo(
@@ -157,6 +163,9 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
filterState,
});
const datatype: GenericDataType = coltypeMap[col];
+ const isLikeOperator =
+ operatorType !== SelectFilterOperatorType.Exact &&
+ datatype === GenericDataType.String;
const labelFormatter = useMemo(
() =>
getDataRecordFormatter({
@@ -170,6 +179,16 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
: filterState?.excludeFilterValues,
);
+ const [likeInputValue, setLikeInputValue] = useState(
+ filterState.value?.[0] != null ? String(filterState.value[0]) : '',
+ );
+
+ useEffect(() => {
+ const externalValue =
+ filterState.value?.[0] != null ? String(filterState.value[0]) : '';
+ setLikeInputValue(externalValue);
+ }, [filterState.value]);
+
const prevExcludeFilterValues = useRef(excludeFilterValues);
const hasOnlyOrientationChanged = useRef(false);
@@ -207,6 +226,7 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
values,
emptyFilter,
excludeFilterValues && inverseSelection,
+ operatorType,
),
filterState: {
...filterState,
@@ -233,6 +253,7 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
enableEmptyFilter,
inverseSelection,
excludeFilterValues,
+ operatorType,
JSON.stringify(filterState),
labelFormatter,
],
@@ -452,6 +473,7 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
updateDataMask(null);
setSearch('');
+ setLikeInputValue('');
onClearAllComplete?.(formData.nativeFilterId);
}
}, [clearAllTrigger, onClearAllComplete, updateDataMask]);
@@ -465,6 +487,7 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
filterState.value,
!filterState.value?.length,
excludeFilterValues && inverseSelection,
+ operatorType,
),
filterState: {
...(filterState as {
@@ -483,6 +506,52 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
setExcludeFilterValues(value === 'true');
};
+ const updateDataMaskRef = useRef(updateDataMask);
+ useEffect(() => {
+ updateDataMaskRef.current = updateDataMask;
+ }, [updateDataMask]);
+
+ const debouncedLikeChange = useMemo(
+ () =>
+ debounce((text: string) => {
+ if (text) {
+ updateDataMaskRef.current([text]);
+ } else {
+ updateDataMaskRef.current(null);
+ }
+ }, Constants.SLOW_DEBOUNCE),
+ [],
+ );
+
+ useEffect(() => {
+ if (!isLikeOperator || clearAllTrigger) {
+ debouncedLikeChange.cancel();
+ }
+ }, [clearAllTrigger, debouncedLikeChange, isLikeOperator]);
+
+ useEffect(() => () => debouncedLikeChange.cancel(), [debouncedLikeChange]);
+
+ const handleLikeInputChange = useCallback(
+ (e: React.ChangeEvent) => {
+ setLikeInputValue(e.target.value);
+ debouncedLikeChange(e.target.value);
+ },
+ [debouncedLikeChange],
+ );
+
+ const likeInputPlaceholder = useMemo(() => {
+ switch (operatorType) {
+ case SelectFilterOperatorType.Contains:
+ return t('Type to search (contains)...');
+ case SelectFilterOperatorType.StartsWith:
+ return t('Type to search (starts with)...');
+ case SelectFilterOperatorType.EndsWith:
+ return t('Type to search (ends with)...');
+ default:
+ return t('Type a value...');
+ }
+ }, [operatorType]);
+
return (
)}
- (parentRef?.current as HTMLElement) || document.body
- : (trigger: HTMLElement) =>
- (trigger?.parentNode as HTMLElement) || document.body
- }
- showSearch={showSearch}
- mode={multiSelect ? 'multiple' : 'single'}
- placeholder={placeholderText}
- onClear={() => onSearch('')}
- onSearch={onSearch}
- onBlur={handleBlur}
- onFocus={setFocusedFilter}
- onMouseEnter={setHoveredFilter}
- onMouseLeave={unsetHoveredFilter}
- // @ts-expect-error
- onChange={handleChange}
- ref={inputRef}
- loading={isRefreshing}
- oneLine={filterBarOrientation === FilterBarOrientation.Horizontal}
- invertSelection={inverseSelection && excludeFilterValues}
- options={options}
- sortComparator={sortComparator}
- onOpenChange={setFilterActive}
- className="select-container"
- />
+ {isLikeOperator ? (
+
+ ) : (
+ (parentRef?.current as HTMLElement) || document.body
+ : (trigger: HTMLElement) =>
+ (trigger?.parentNode as HTMLElement) || document.body
+ }
+ showSearch={showSearch}
+ mode={multiSelect ? 'multiple' : 'single'}
+ placeholder={placeholderText}
+ onClear={() => onSearch('')}
+ onSearch={onSearch}
+ onBlur={handleBlur}
+ onFocus={setFocusedFilter}
+ onMouseEnter={setHoveredFilter}
+ onMouseLeave={unsetHoveredFilter}
+ // @ts-expect-error
+ onChange={handleChange}
+ ref={inputRef}
+ loading={isRefreshing}
+ oneLine={filterBarOrientation === FilterBarOrientation.Horizontal}
+ invertSelection={inverseSelection && excludeFilterValues}
+ options={options}
+ sortComparator={sortComparator}
+ onOpenChange={setFilterActive}
+ className="select-container"
+ />
+ )}