mirror of
https://github.com/apache/superset.git
synced 2026-04-19 08:04:53 +00:00
feat(native-filters): add configurable LIKE/ILIKE operators to Select filter (#38470)
Co-authored-by: Evan Rusackas <evan@preset.io> Co-authored-by: Richard Fogaca Nienkotter <63572350+richardfogaca@users.noreply.github.com>
This commit is contained in:
@@ -30,6 +30,7 @@ const BINARY_OPERATORS = [
|
||||
'<=',
|
||||
'ILIKE',
|
||||
'LIKE',
|
||||
'NOT ILIKE',
|
||||
'NOT LIKE',
|
||||
'REGEX',
|
||||
'TEMPORAL_RANGE',
|
||||
|
||||
@@ -45,12 +45,16 @@ import {
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
RefObject,
|
||||
memo,
|
||||
} from 'react';
|
||||
import rison from 'rison';
|
||||
import { PluginFilterSelectCustomizeProps } from 'src/filters/components/Select/types';
|
||||
import {
|
||||
PluginFilterSelectCustomizeProps,
|
||||
SelectFilterOperatorType,
|
||||
} from 'src/filters/components/Select/types';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { getChartDataRequest } from 'src/components/Chart/chartAction';
|
||||
import {
|
||||
@@ -624,6 +628,49 @@ const FiltersConfigForm = (
|
||||
forceUpdate();
|
||||
};
|
||||
|
||||
const currentOperatorType: SelectFilterOperatorType =
|
||||
formFilter?.controlValues?.operatorType ??
|
||||
filterToEdit?.controlValues?.operatorType ??
|
||||
SelectFilterOperatorType.Exact;
|
||||
|
||||
const selectedColumnIsString = useMemo(() => {
|
||||
const columnName = formFilter?.column;
|
||||
if (!columnName || !datasetDetails?.columns) return true;
|
||||
const colMeta = datasetDetails.columns.find(
|
||||
(c: { column_name: string }) => c.column_name === columnName,
|
||||
);
|
||||
if (!colMeta) return true;
|
||||
return colMeta.type_generic === GenericDataType.String;
|
||||
}, [formFilter?.column, datasetDetails?.columns]);
|
||||
|
||||
const onOperatorTypeChanged = (value: SelectFilterOperatorType) => {
|
||||
const previous = form.getFieldValue('filters')?.[filterId].controlValues;
|
||||
setNativeFilterFieldValues(form, filterId, {
|
||||
controlValues: {
|
||||
...previous,
|
||||
operatorType: value,
|
||||
},
|
||||
defaultDataMask: null,
|
||||
});
|
||||
formChanged();
|
||||
forceUpdate();
|
||||
};
|
||||
|
||||
const prevColumnRef = useRef(formFilter?.column);
|
||||
const datasetLoaded = !!datasetDetails?.columns;
|
||||
useEffect(() => {
|
||||
const columnChanged = prevColumnRef.current !== formFilter?.column;
|
||||
if (
|
||||
(columnChanged || datasetLoaded) &&
|
||||
!selectedColumnIsString &&
|
||||
currentOperatorType !== SelectFilterOperatorType.Exact
|
||||
) {
|
||||
onOperatorTypeChanged(SelectFilterOperatorType.Exact);
|
||||
}
|
||||
prevColumnRef.current = formFilter?.column;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [formFilter?.column, selectedColumnIsString, datasetLoaded]);
|
||||
|
||||
const validatePreFilter = () =>
|
||||
setTimeout(
|
||||
() =>
|
||||
@@ -685,6 +732,7 @@ const FiltersConfigForm = (
|
||||
'columns.filterable',
|
||||
'columns.is_dttm',
|
||||
'columns.type',
|
||||
'columns.type_generic',
|
||||
'columns.verbose_name',
|
||||
'database.id',
|
||||
'database.database_name',
|
||||
@@ -1501,6 +1549,67 @@ const FiltersConfigForm = (
|
||||
hidden
|
||||
initialValue={null}
|
||||
/>
|
||||
{!isChartCustomization &&
|
||||
itemTypeField === 'filter_select' && (
|
||||
<StyledRowFormItem
|
||||
expanded={expanded}
|
||||
name={[
|
||||
'filters',
|
||||
filterId,
|
||||
'controlValues',
|
||||
'operatorType',
|
||||
]}
|
||||
initialValue={currentOperatorType}
|
||||
label={
|
||||
<>
|
||||
<StyledLabel>{t('Match type')}</StyledLabel>
|
||||
|
||||
<InfoTooltip
|
||||
placement="top"
|
||||
tooltip={t(
|
||||
'Warning: ILIKE queries may be slow on large datasets as they cannot use indexes effectively.',
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Select
|
||||
ariaLabel={t('Match type')}
|
||||
options={[
|
||||
{
|
||||
value: SelectFilterOperatorType.Exact,
|
||||
label: t('Exact match (IN)'),
|
||||
},
|
||||
...(selectedColumnIsString
|
||||
? [
|
||||
{
|
||||
value:
|
||||
SelectFilterOperatorType.Contains,
|
||||
label: t(
|
||||
'Contains text (ILIKE %x%)',
|
||||
),
|
||||
},
|
||||
{
|
||||
value:
|
||||
SelectFilterOperatorType.StartsWith,
|
||||
label: t('Starts with (ILIKE x%)'),
|
||||
},
|
||||
{
|
||||
value:
|
||||
SelectFilterOperatorType.EndsWith,
|
||||
label: t('Ends with (ILIKE %x)'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
onChange={value => {
|
||||
onOperatorTypeChanged(
|
||||
value as SelectFilterOperatorType,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</StyledRowFormItem>
|
||||
)}
|
||||
<FormItem
|
||||
name={['filters', filterId, 'defaultValue']}
|
||||
>
|
||||
|
||||
@@ -165,7 +165,8 @@ export default function getControlItemsMap({
|
||||
(controlItem: CustomControlItem) =>
|
||||
controlItem?.config?.renderTrigger &&
|
||||
controlItem.name !== 'sortAscending' &&
|
||||
controlItem.name !== 'enableSingleValue',
|
||||
controlItem.name !== 'enableSingleValue' &&
|
||||
controlItem.name !== 'operatorType',
|
||||
)
|
||||
.forEach(controlItem => {
|
||||
const initialValue =
|
||||
|
||||
@@ -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<PluginFilterSelectQueryFormData>;
|
||||
filterState?: Partial<FilterState>;
|
||||
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(<SelectFilterPlugin {...props} />, {
|
||||
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(<SelectFilterPlugin {...props} />, {
|
||||
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(<SelectFilterPlugin {...props} />, {
|
||||
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(<SelectFilterPlugin {...props} />, {
|
||||
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(
|
||||
<SelectFilterPlugin {...likeProps} />,
|
||||
reduxState,
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText('Type to search (contains)...');
|
||||
expect(input).toHaveValue('Jen');
|
||||
|
||||
setDataMaskMock.mockClear();
|
||||
|
||||
rerender(
|
||||
<SelectFilterPlugin
|
||||
{...likeProps}
|
||||
clearAllTrigger={{ 'test-filter': true }}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<SelectFilterPlugin {...likeProps} />,
|
||||
reduxState,
|
||||
);
|
||||
|
||||
fireEvent.change(
|
||||
screen.getByPlaceholderText('Type to search (contains)...'),
|
||||
{
|
||||
target: { value: 'Jen' },
|
||||
},
|
||||
);
|
||||
|
||||
setDataMaskMock.mockClear();
|
||||
|
||||
rerender(
|
||||
<SelectFilterPlugin
|
||||
{...buildSelectFilterProps({
|
||||
formData: { operatorType: SelectFilterOperatorType.Contains },
|
||||
filterState: { value: undefined, label: 'external change' },
|
||||
setDataMask: setDataMaskMock,
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<SelectFilterPlugin {...likeProps} />,
|
||||
reduxState,
|
||||
);
|
||||
|
||||
fireEvent.change(
|
||||
screen.getByPlaceholderText('Type to search (contains)...'),
|
||||
{
|
||||
target: { value: 'Jen' },
|
||||
},
|
||||
);
|
||||
|
||||
setDataMaskMock.mockClear();
|
||||
|
||||
rerender(
|
||||
<SelectFilterPlugin
|
||||
{...buildSelectFilterProps({
|
||||
formData: { operatorType: SelectFilterOperatorType.Exact },
|
||||
filterState: { value: undefined },
|
||||
setDataMask: setDataMaskMock,
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(<SelectFilterPlugin {...props} />, {
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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<string>(
|
||||
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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<FilterPluginStyle height={height} width={width}>
|
||||
<FormItem
|
||||
@@ -504,39 +573,54 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
|
||||
onChange={handleExclusionToggle}
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
name={formData.nativeFilterId}
|
||||
allowClear
|
||||
allowNewOptions={!searchAllOptions && creatable !== false}
|
||||
allowSelectAll={!searchAllOptions}
|
||||
value={multiSelect ? filterState.value || [] : filterState.value}
|
||||
disabled={isDisabled}
|
||||
getPopupContainer={
|
||||
showOverflow
|
||||
? () => (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 ? (
|
||||
<Input
|
||||
allowClear
|
||||
placeholder={likeInputPlaceholder}
|
||||
value={likeInputValue}
|
||||
onChange={handleLikeInputChange}
|
||||
onFocus={setFocusedFilter}
|
||||
onBlur={unsetFocusedFilter}
|
||||
onMouseEnter={setHoveredFilter}
|
||||
onMouseLeave={unsetHoveredFilter}
|
||||
disabled={isDisabled}
|
||||
ref={inputRef}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
name={formData.nativeFilterId}
|
||||
allowClear
|
||||
allowNewOptions={!searchAllOptions && creatable !== false}
|
||||
allowSelectAll={!searchAllOptions}
|
||||
value={multiSelect ? filterState.value || [] : filterState.value}
|
||||
disabled={isDisabled}
|
||||
getPopupContainer={
|
||||
showOverflow
|
||||
? () => (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"
|
||||
/>
|
||||
)}
|
||||
</StyledSpace>
|
||||
</FormItem>
|
||||
</FilterPluginStyle>
|
||||
|
||||
@@ -18,7 +18,11 @@
|
||||
*/
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import buildQuery from './buildQuery';
|
||||
import { PluginFilterSelectQueryFormData } from './types';
|
||||
import {
|
||||
PluginFilterSelectQueryFormData,
|
||||
SelectFilterOperatorType,
|
||||
} from './types';
|
||||
import { getSelectExtraFormData } from '../../utils';
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('Select buildQuery', () => {
|
||||
@@ -127,3 +131,91 @@ describe('Select buildQuery', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test('getSelectExtraFormData generates IN filter by default', () => {
|
||||
const result = getSelectExtraFormData('name', ['Jennifer']);
|
||||
expect(result.filters).toEqual([
|
||||
{ col: 'name', op: 'IN', val: ['Jennifer'] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('getSelectExtraFormData generates NOT IN filter with excludeFilter', () => {
|
||||
const result = getSelectExtraFormData('name', ['Jennifer'], false, true);
|
||||
expect(result.filters).toEqual([
|
||||
{ col: 'name', op: 'NOT IN', val: ['Jennifer'] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('getSelectExtraFormData generates ILIKE contains filter', () => {
|
||||
const result = getSelectExtraFormData(
|
||||
'name',
|
||||
['Jen'],
|
||||
false,
|
||||
false,
|
||||
SelectFilterOperatorType.Contains,
|
||||
);
|
||||
expect(result.filters).toEqual([{ col: 'name', op: 'ILIKE', val: '%Jen%' }]);
|
||||
});
|
||||
|
||||
test('getSelectExtraFormData generates ILIKE starts-with filter', () => {
|
||||
const result = getSelectExtraFormData(
|
||||
'name',
|
||||
['Jen'],
|
||||
false,
|
||||
false,
|
||||
SelectFilterOperatorType.StartsWith,
|
||||
);
|
||||
expect(result.filters).toEqual([{ col: 'name', op: 'ILIKE', val: 'Jen%' }]);
|
||||
});
|
||||
|
||||
test('getSelectExtraFormData generates ILIKE ends-with filter', () => {
|
||||
const result = getSelectExtraFormData(
|
||||
'name',
|
||||
['son'],
|
||||
false,
|
||||
false,
|
||||
SelectFilterOperatorType.EndsWith,
|
||||
);
|
||||
expect(result.filters).toEqual([{ col: 'name', op: 'ILIKE', val: '%son' }]);
|
||||
});
|
||||
|
||||
test('getSelectExtraFormData generates NOT ILIKE with excludeFilter and LIKE operator', () => {
|
||||
const result = getSelectExtraFormData(
|
||||
'name',
|
||||
['Jen'],
|
||||
false,
|
||||
true,
|
||||
SelectFilterOperatorType.Contains,
|
||||
);
|
||||
expect(result.filters).toEqual([
|
||||
{ col: 'name', op: 'NOT ILIKE', val: '%Jen%' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('getSelectExtraFormData returns empty object for null value with LIKE operator', () => {
|
||||
const result = getSelectExtraFormData(
|
||||
'name',
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
SelectFilterOperatorType.Contains,
|
||||
);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
test('getSelectExtraFormData returns adhoc_filters for emptyFilter with LIKE operator', () => {
|
||||
const result = getSelectExtraFormData(
|
||||
'name',
|
||||
[],
|
||||
true,
|
||||
false,
|
||||
SelectFilterOperatorType.Contains,
|
||||
);
|
||||
expect(result.adhoc_filters).toEqual([
|
||||
{
|
||||
expressionType: 'SQL',
|
||||
clause: 'WHERE',
|
||||
sqlExpression: '1 = 0',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 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 { GenericDataType } from '@apache-superset/core/common';
|
||||
import { getOperatorTypeChoices, isStringOperatorColumn } from './controlPanel';
|
||||
import { SelectFilterOperatorType } from './types';
|
||||
|
||||
test('getOperatorTypeChoices only returns exact match for non-string columns', () => {
|
||||
expect(getOperatorTypeChoices(false)).toEqual([
|
||||
[SelectFilterOperatorType.Exact, 'Exact match (IN)'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('isStringOperatorColumn returns false for numeric selected columns', () => {
|
||||
expect(
|
||||
isStringOperatorColumn('num_col', [
|
||||
{ column_name: 'num_col', type_generic: GenericDataType.Numeric },
|
||||
]),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('isStringOperatorColumn returns true for string selected columns', () => {
|
||||
expect(
|
||||
isStringOperatorColumn('name', [
|
||||
{ column_name: 'name', type_generic: GenericDataType.String },
|
||||
]),
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -17,12 +17,13 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { validateNonEmpty } from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import { ensureIsArray, validateNonEmpty } from '@superset-ui/core';
|
||||
import {
|
||||
ControlPanelConfig,
|
||||
sharedControls,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import { DEFAULT_FORM_DATA } from './types';
|
||||
import { DEFAULT_FORM_DATA, SelectFilterOperatorType } from './types';
|
||||
|
||||
const {
|
||||
enableEmptyFilter,
|
||||
@@ -32,8 +33,40 @@ const {
|
||||
defaultToFirstItem,
|
||||
searchAllOptions,
|
||||
sortAscending,
|
||||
operatorType,
|
||||
} = DEFAULT_FORM_DATA;
|
||||
|
||||
type FilterSelectColumn = {
|
||||
column_name: string;
|
||||
type_generic?: GenericDataType | null;
|
||||
};
|
||||
|
||||
export const getOperatorTypeChoices = (isStringColumn: boolean) => [
|
||||
[SelectFilterOperatorType.Exact, t('Exact match (IN)')],
|
||||
...(isStringColumn
|
||||
? [
|
||||
[SelectFilterOperatorType.Contains, t('Contains text (ILIKE %x%)')],
|
||||
[SelectFilterOperatorType.StartsWith, t('Starts with (ILIKE x%)')],
|
||||
[SelectFilterOperatorType.EndsWith, t('Ends with (ILIKE %x)')],
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
export const isStringOperatorColumn = (
|
||||
selectedColumn: unknown,
|
||||
columns?: FilterSelectColumn[],
|
||||
) => {
|
||||
const columnName = ensureIsArray(selectedColumn)[0];
|
||||
if (!columnName || !columns) {
|
||||
return true;
|
||||
}
|
||||
const column = columns.find(col => col.column_name === columnName);
|
||||
if (!column || column.type_generic == null) {
|
||||
return true;
|
||||
}
|
||||
return column.type_generic === GenericDataType.String;
|
||||
};
|
||||
|
||||
const config: ControlPanelConfig = {
|
||||
controlPanelSections: [
|
||||
{
|
||||
@@ -56,6 +89,44 @@ const config: ControlPanelConfig = {
|
||||
label: t('UI Configuration'),
|
||||
expanded: true,
|
||||
controlSetRows: [
|
||||
[
|
||||
{
|
||||
name: 'operatorType',
|
||||
config: {
|
||||
type: 'SelectControl',
|
||||
renderTrigger: true,
|
||||
affectsDataMask: true,
|
||||
label: t('Match type'),
|
||||
default: operatorType,
|
||||
choices: getOperatorTypeChoices(true),
|
||||
description: t(
|
||||
'Determines how the filter matches values. ' +
|
||||
'"Exact match" uses the IN operator (default). ' +
|
||||
'ILIKE options enable partial text matching with a free-text input. ' +
|
||||
'Warning: ILIKE queries may be slow on large datasets as they cannot use indexes effectively.',
|
||||
),
|
||||
mapStateToProps: state => {
|
||||
const isStringColumn = isStringOperatorColumn(
|
||||
state.controls?.groupby?.value,
|
||||
state.datasource?.columns as FilterSelectColumn[] | undefined,
|
||||
);
|
||||
return {
|
||||
choices: getOperatorTypeChoices(isStringColumn),
|
||||
description: isStringColumn
|
||||
? t(
|
||||
'Determines how the filter matches values. ' +
|
||||
'"Exact match" uses the IN operator (default). ' +
|
||||
'ILIKE options enable partial text matching with a free-text input. ' +
|
||||
'Warning: ILIKE queries may be slow on large datasets as they cannot use indexes effectively.',
|
||||
)
|
||||
: t(
|
||||
'Only exact match is available for non-string columns.',
|
||||
),
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'sortAscending',
|
||||
|
||||
@@ -18,7 +18,11 @@
|
||||
*/
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import { noOp } from 'src/utils/common';
|
||||
import { DEFAULT_FORM_DATA, PluginFilterSelectChartProps } from './types';
|
||||
import {
|
||||
DEFAULT_FORM_DATA,
|
||||
PluginFilterSelectChartProps,
|
||||
PluginFilterSelectQueryFormData,
|
||||
} from './types';
|
||||
|
||||
export default function transformProps(
|
||||
chartProps: PluginFilterSelectChartProps,
|
||||
@@ -36,7 +40,10 @@ export default function transformProps(
|
||||
isRefreshing,
|
||||
inputRef,
|
||||
} = chartProps;
|
||||
const newFormData = { ...DEFAULT_FORM_DATA, ...formData };
|
||||
const newFormData = {
|
||||
...DEFAULT_FORM_DATA,
|
||||
...(formData as PluginFilterSelectQueryFormData),
|
||||
};
|
||||
const {
|
||||
setDataMask = noOp,
|
||||
setHoveredFilter = noOp,
|
||||
|
||||
@@ -32,6 +32,13 @@ import { PluginFilterHooks, PluginFilterStylesProps } from '../types';
|
||||
|
||||
export type SelectValue = (number | string | null)[] | null | undefined;
|
||||
|
||||
export enum SelectFilterOperatorType {
|
||||
Exact = 'exact',
|
||||
Contains = 'ilike_contains',
|
||||
StartsWith = 'ilike_starts_with',
|
||||
EndsWith = 'ilike_ends_with',
|
||||
}
|
||||
|
||||
export interface PluginFilterSelectCustomizeProps {
|
||||
defaultValue?: SelectValue;
|
||||
enableEmptyFilter: boolean;
|
||||
@@ -42,6 +49,7 @@ export interface PluginFilterSelectCustomizeProps {
|
||||
searchAllOptions: boolean;
|
||||
sortAscending?: boolean;
|
||||
sortMetric?: string;
|
||||
operatorType?: SelectFilterOperatorType;
|
||||
}
|
||||
|
||||
export type PluginFilterSelectQueryFormData = QueryFormData &
|
||||
@@ -78,4 +86,5 @@ export const DEFAULT_FORM_DATA: PluginFilterSelectCustomizeProps = {
|
||||
multiSelect: true,
|
||||
searchAllOptions: false,
|
||||
sortAscending: true,
|
||||
operatorType: SelectFilterOperatorType.Exact,
|
||||
};
|
||||
|
||||
@@ -29,12 +29,30 @@ import {
|
||||
Clauses,
|
||||
ExpressionTypes,
|
||||
} from '../explore/components/controls/FilterControl/types';
|
||||
import { SelectFilterOperatorType } from './components/Select/types';
|
||||
|
||||
function applyWildcard(
|
||||
value: string,
|
||||
operatorType: SelectFilterOperatorType,
|
||||
): string {
|
||||
switch (operatorType) {
|
||||
case SelectFilterOperatorType.Contains:
|
||||
return `%${value}%`;
|
||||
case SelectFilterOperatorType.StartsWith:
|
||||
return `${value}%`;
|
||||
case SelectFilterOperatorType.EndsWith:
|
||||
return `%${value}`;
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export const getSelectExtraFormData = (
|
||||
col: string,
|
||||
value?: null | (string | number | boolean | null)[],
|
||||
emptyFilter = false,
|
||||
shouldExcludeFilter = false,
|
||||
operatorType: SelectFilterOperatorType = SelectFilterOperatorType.Exact,
|
||||
): ExtraFormData => {
|
||||
const extra: ExtraFormData = {};
|
||||
if (emptyFilter) {
|
||||
@@ -46,13 +64,26 @@ export const getSelectExtraFormData = (
|
||||
},
|
||||
];
|
||||
} else if (value !== undefined && value !== null && value.length !== 0) {
|
||||
extra.filters = [
|
||||
{
|
||||
col,
|
||||
op: shouldExcludeFilter ? ('NOT IN' as const) : ('IN' as const),
|
||||
val: value,
|
||||
},
|
||||
];
|
||||
const isLikeOperator = operatorType !== SelectFilterOperatorType.Exact;
|
||||
|
||||
if (isLikeOperator && typeof value[0] === 'string') {
|
||||
const wildcardVal = applyWildcard(value[0] as string, operatorType);
|
||||
extra.filters = [
|
||||
{
|
||||
col,
|
||||
op: shouldExcludeFilter ? ('NOT ILIKE' as const) : ('ILIKE' as const),
|
||||
val: wildcardVal,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
extra.filters = [
|
||||
{
|
||||
col,
|
||||
op: shouldExcludeFilter ? ('NOT IN' as const) : ('IN' as const),
|
||||
val: value,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
return extra;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user