feat(native-filters): add search all filter options (#14710)

* feat(native-filters): add search all filter options

* add tests

* fix default value

* implement ILIKE operator

* rebump packages

* fix test

* address comments

* fix state changes coming from application

* fix debouncer
This commit is contained in:
Ville Brofeldt 2021-05-24 16:33:59 +03:00 committed by GitHub
parent 8484ee653f
commit e9657afe4b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 593 additions and 402 deletions

File diff suppressed because it is too large Load Diff

View File

@ -67,35 +67,35 @@
"@emotion/babel-preset-css-prop": "^11.2.0",
"@emotion/cache": "^11.1.3",
"@emotion/react": "^11.1.5",
"@superset-ui/chart-controls": "^0.17.48",
"@superset-ui/core": "^0.17.48",
"@superset-ui/legacy-plugin-chart-calendar": "0.17.48",
"@superset-ui/legacy-plugin-chart-chord": "0.17.48",
"@superset-ui/legacy-plugin-chart-country-map": "0.17.48",
"@superset-ui/legacy-plugin-chart-event-flow": "0.17.48",
"@superset-ui/legacy-plugin-chart-force-directed": "0.17.48",
"@superset-ui/legacy-plugin-chart-heatmap": "0.17.48",
"@superset-ui/legacy-plugin-chart-histogram": "0.17.48",
"@superset-ui/legacy-plugin-chart-horizon": "0.17.48",
"@superset-ui/legacy-plugin-chart-map-box": "0.17.48",
"@superset-ui/legacy-plugin-chart-paired-t-test": "0.17.48",
"@superset-ui/legacy-plugin-chart-parallel-coordinates": "0.17.48",
"@superset-ui/legacy-plugin-chart-partition": "0.17.48",
"@superset-ui/legacy-plugin-chart-pivot-table": "0.17.48",
"@superset-ui/legacy-plugin-chart-rose": "0.17.48",
"@superset-ui/legacy-plugin-chart-sankey": "^0.17.48",
"@superset-ui/legacy-plugin-chart-sankey-loop": "0.17.48",
"@superset-ui/legacy-plugin-chart-sunburst": "0.17.48",
"@superset-ui/legacy-plugin-chart-treemap": "0.17.48",
"@superset-ui/legacy-plugin-chart-world-map": "0.17.48",
"@superset-ui/legacy-preset-chart-big-number": "0.17.48",
"@superset-ui/chart-controls": "^0.17.50",
"@superset-ui/core": "^0.17.50",
"@superset-ui/legacy-plugin-chart-calendar": "^0.17.50",
"@superset-ui/legacy-plugin-chart-chord": "^0.17.50",
"@superset-ui/legacy-plugin-chart-country-map": "^0.17.50",
"@superset-ui/legacy-plugin-chart-event-flow": "^0.17.50",
"@superset-ui/legacy-plugin-chart-force-directed": "^0.17.50",
"@superset-ui/legacy-plugin-chart-heatmap": "^0.17.50",
"@superset-ui/legacy-plugin-chart-histogram": "^0.17.50",
"@superset-ui/legacy-plugin-chart-horizon": "^0.17.50",
"@superset-ui/legacy-plugin-chart-map-box": "^0.17.50",
"@superset-ui/legacy-plugin-chart-paired-t-test": "^0.17.50",
"@superset-ui/legacy-plugin-chart-parallel-coordinates": "^0.17.50",
"@superset-ui/legacy-plugin-chart-partition": "^0.17.50",
"@superset-ui/legacy-plugin-chart-pivot-table": "^0.17.50",
"@superset-ui/legacy-plugin-chart-rose": "^0.17.50",
"@superset-ui/legacy-plugin-chart-sankey": "^0.17.50",
"@superset-ui/legacy-plugin-chart-sankey-loop": "^0.17.50",
"@superset-ui/legacy-plugin-chart-sunburst": "^0.17.50",
"@superset-ui/legacy-plugin-chart-treemap": "^0.17.50",
"@superset-ui/legacy-plugin-chart-world-map": "^0.17.50",
"@superset-ui/legacy-preset-chart-big-number": "^0.17.50",
"@superset-ui/legacy-preset-chart-deckgl": "^0.4.6",
"@superset-ui/legacy-preset-chart-nvd3": "0.17.48",
"@superset-ui/plugin-chart-echarts": "^0.17.48",
"@superset-ui/plugin-chart-pivot-table": "^0.17.48",
"@superset-ui/plugin-chart-table": "^0.17.48",
"@superset-ui/plugin-chart-word-cloud": "0.17.48",
"@superset-ui/preset-chart-xy": "0.17.48",
"@superset-ui/legacy-preset-chart-nvd3": "^0.17.50",
"@superset-ui/plugin-chart-echarts": "^0.17.50",
"@superset-ui/plugin-chart-pivot-table": "^0.17.50",
"@superset-ui/plugin-chart-table": "^0.17.50",
"@superset-ui/plugin-chart-word-cloud": "^0.17.50",
"@superset-ui/preset-chart-xy": "^0.17.50",
"@vx/responsive": "^0.0.195",
"abortcontroller-polyfill": "^1.1.9",
"antd": "^4.9.4",

View File

@ -25,6 +25,8 @@ import {
t,
Behavior,
ChartDataResponseResult,
JsonObject,
getChartMetadataRegistry,
} from '@superset-ui/core';
import { useDispatch } from 'react-redux';
import { areObjectsEqual } from 'src/reduxUtils';
@ -53,10 +55,12 @@ const FilterValue: React.FC<FilterProps> = ({
onFilterSelectionChange,
}) => {
const { id, targets, filterType, adhoc_filters, time_range } = filter;
const metadata = getChartMetadataRegistry().get(filterType);
const cascadingFilters = useCascadingFilters(id);
const [state, setState] = useState<ChartDataResponseResult[]>([]);
const [error, setError] = useState<string>('');
const [formData, setFormData] = useState<Partial<QueryFormData>>({});
const [ownState, setOwnState] = useState<JsonObject>({});
const inputRef = useRef<HTMLInputElement>(null);
const [target] = targets;
const {
@ -65,7 +69,8 @@ const FilterValue: React.FC<FilterProps> = ({
}: Partial<{ datasetId: number; column: { name?: string } }> = target;
const { name: groupby } = column;
const hasDataSource = !!datasetId;
const [loading, setLoading] = useState<boolean>(hasDataSource);
const [isLoading, setIsLoading] = useState<boolean>(hasDataSource);
const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
const dispatch = useDispatch();
useEffect(() => {
const newFormData = getFormData({
@ -77,16 +82,22 @@ const FilterValue: React.FC<FilterProps> = ({
adhoc_filters,
time_range,
});
if (!areObjectsEqual(formData, newFormData)) {
const filterOwnState = filter.dataMask?.ownState || {};
if (
!areObjectsEqual(formData, newFormData) ||
!areObjectsEqual(ownState, filterOwnState)
) {
setFormData(newFormData);
setOwnState(filterOwnState);
if (!hasDataSource) {
return;
}
setIsRefreshing(true);
getChartDataRequest({
formData: newFormData,
force: false,
requestParams: { dashboardId: 0 },
ownState: filter.dataMask?.ownState,
ownState: filterOwnState,
})
.then(response => {
if (isFeatureEnabled(FeatureFlag.GLOBAL_ASYNC_QUERIES)) {
@ -94,24 +105,28 @@ const FilterValue: React.FC<FilterProps> = ({
const result = 'result' in response ? response.result[0] : response;
waitForAsyncData(result)
.then((asyncResult: ChartDataResponseResult[]) => {
setLoading(false);
setIsRefreshing(false);
setIsLoading(false);
setState(asyncResult);
})
.catch((error: ClientErrorObject) => {
setError(
error.message || error.error || t('Check configuration'),
);
setLoading(false);
setIsRefreshing(false);
setIsLoading(false);
});
} else {
setState(response.result);
setError('');
setLoading(false);
setIsRefreshing(false);
setIsLoading(false);
}
})
.catch((error: Response) => {
setError(error.statusText);
setLoading(false);
setIsRefreshing(false);
setIsLoading(false);
});
}
}, [
@ -151,7 +166,7 @@ const FilterValue: React.FC<FilterProps> = ({
return (
<FilterItem data-test="form-item-value">
{loading ? (
{isLoading ? (
<Loading position="inline-centered" />
) : (
<SuperChart
@ -164,6 +179,8 @@ const FilterValue: React.FC<FilterProps> = ({
behaviors={[Behavior.NATIVE_FILTER]}
filterState={filter.dataMask?.filterState}
ownState={filter.dataMask?.ownState}
enableNoResults={metadata?.enableNoResults}
isRefreshing={isRefreshing}
hooks={{ setDataMask, setFocusedFilter, unsetFocusedFilter }}
/>
)}

View File

@ -34,6 +34,7 @@ type DefaultValueProps = {
hasDataset: boolean;
form: FormInstance<NativeFiltersForm>;
formData: ReturnType<typeof getFormData>;
enableNoResults: boolean;
};
const DefaultValue: FC<DefaultValueProps> = ({
@ -42,6 +43,7 @@ const DefaultValue: FC<DefaultValueProps> = ({
form,
setDataMask,
formData,
enableNoResults,
}) => {
const [loading, setLoading] = useState(hasDataset);
const formFilter = (form.getFieldValue('filters') || {})[filterId];
@ -70,6 +72,7 @@ const DefaultValue: FC<DefaultValueProps> = ({
}
chartType={formFilter?.filterType}
hooks={{ setDataMask }}
enableNoResults={enableNoResults}
/>
);
};

View File

@ -204,7 +204,9 @@ export const FiltersConfigForm: React.FC<FiltersConfigFormProps> = ({
?.datasourceCount;
const hasColumn =
hasDataset && !FILTERS_WITHOUT_COLUMN.includes(formFilter?.filterType);
// @ts-ignore
const enableNoResults = !!nativeFilterItems[formFilter?.filterType]?.value
?.enableNoResults;
const datasetId = formFilter?.dataset?.value;
useEffect(() => {
@ -484,6 +486,7 @@ export const FiltersConfigForm: React.FC<FiltersConfigFormProps> = ({
hasDataset={hasDataset}
form={form}
formData={newFormData}
enableNoResults={enableNoResults}
/>
) : hasFilledDataset ? (
t('Click "Populate" to get "Default Value" ->')

View File

@ -72,7 +72,7 @@ export const getFormData = ({
extra_form_data: cascadingFilters,
granularity_sqla: 'ds',
metrics: ['count'],
row_limit: 10000,
row_limit: 1000,
showSearch: true,
defaultValue: defaultDataMask?.filterState?.value,
time_range,

View File

@ -39,6 +39,7 @@ const OPERATORS_TO_SQL = {
IN: 'IN',
'NOT IN': 'NOT IN',
LIKE: 'LIKE',
ILIKE: 'ILIKE',
REGEX: 'REGEX',
'IS NOT NULL': 'IS NOT NULL',
'IS NULL': 'IS NULL',

View File

@ -84,6 +84,9 @@ function translateOperator(operator) {
if (operator === OPERATORS.LIKE) {
return 'LIKE';
}
if (operator === OPERATORS.ILIKE) {
return 'LIKE (case insensitive)';
}
if (operator === OPERATORS['LATEST PARTITION']) {
return 'use latest_partition template';
}

View File

@ -37,6 +37,7 @@ export const OPERATORS = {
'<=': '<=',
IN: 'IN',
'NOT IN': 'NOT IN',
ILIKE: 'ILIKE',
LIKE: 'LIKE',
REGEX: 'REGEX',
'IS NOT NULL': 'IS NOT NULL',
@ -48,7 +49,7 @@ export const OPERATORS = {
export const OPERATORS_OPTIONS = Object.values(OPERATORS);
export const TABLE_ONLY_OPERATORS = [OPERATORS.LIKE];
export const TABLE_ONLY_OPERATORS = [OPERATORS.LIKE, OPERATORS.ILIKE];
export const DRUID_ONLY_OPERATORS = [OPERATORS.REGEX];
export const HAVING_OPERATORS = [
OPERATORS['=='],

View File

@ -18,31 +18,76 @@
*/
import {
AppSection,
DataMask,
ensureIsArray,
ExtraFormData,
GenericDataType,
JsonObject,
smartDateDetailedFormatter,
t,
tn,
} from '@superset-ui/core';
import React, { useEffect, useState } from 'react';
import React, { useCallback, useEffect, useReducer, useState } from 'react';
import { Select } from 'src/common/components';
import { FIRST_VALUE, PluginFilterSelectProps, SelectValue } from './types';
import { debounce } from 'lodash';
import { SLOW_DEBOUNCE } from 'src/constants';
import { PluginFilterSelectProps, SelectValue } from './types';
import { StyledSelect, Styles } from '../common';
import { getDataRecordFormatter, getSelectExtraFormData } from '../../utils';
const { Option } = Select;
type DataMaskAction =
| { type: 'ownState'; ownState: JsonObject }
| {
type: 'filterState';
extraFormData: ExtraFormData;
filterState: { value: SelectValue };
};
function reducer(state: DataMask, action: DataMaskAction): DataMask {
switch (action.type) {
case 'ownState':
return {
...state,
ownState: {
...(state.ownState || {}),
...action.ownState,
},
};
case 'filterState':
return {
...state,
extraFormData: action.extraFormData,
filterState: {
...(state.filterState || {}),
...action.filterState,
},
};
default:
return {
...state,
};
}
}
type DataMaskReducer = (
prevState: DataMask,
action: DataMaskAction,
) => DataMask;
export default function PluginFilterSelect(props: PluginFilterSelectProps) {
const {
coltypeMap,
data,
filterState,
formData,
height,
isRefreshing,
width,
setDataMask,
setFocusedFilter,
unsetFocusedFilter,
filterState,
appSection,
} = props;
const {
@ -53,33 +98,75 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
inverseSelection,
inputRef,
defaultToFirstItem,
searchAllOptions,
} = formData;
const forceFirstValue =
const isDisabled =
appSection === AppSection.FILTER_CONFIG_MODAL && defaultToFirstItem;
const groupby = ensureIsArray<string>(formData.groupby);
// Correct initial value for Ant Select
const initSelectValue: SelectValue =
// `defaultValue` can be `FIRST_VALUE` if `defaultToFirstItem` is checked, so need convert it to correct value for Select
defaultValue === FIRST_VALUE ? [] : defaultValue ?? [];
const firstItem: SelectValue = data[0]
? (groupby.map(col => data[0][col]) as string[]) ?? initSelectValue
: initSelectValue;
// If we are in config modal we always need show empty select for `defaultToFirstItem`
const [values, setValues] = useState<SelectValue>(
defaultToFirstItem && appSection !== AppSection.FILTER_CONFIG_MODAL
? firstItem
: initSelectValue,
!isDisabled && defaultValue?.length ? defaultValue : [],
);
const [currentSuggestionSearch, setCurrentSuggestionSearch] = useState('');
const [dataMask, dispatchDataMask] = useReducer<DataMaskReducer>(
reducer,
searchAllOptions
? {
ownState: {
coltypeMap,
},
}
: {},
);
const debouncedOwnStateFunc = useCallback(
debounce((val: string) => {
dispatchDataMask({
type: 'ownState',
ownState: {
search: val,
},
});
}, SLOW_DEBOUNCE),
[],
);
const searchWrapper = (val: string) => {
if (searchAllOptions) {
debouncedOwnStateFunc(val);
}
setCurrentSuggestionSearch(val);
};
const clearSuggestionSearch = () => {
setCurrentSuggestionSearch('');
if (searchAllOptions) {
dispatchDataMask({
type: 'ownState',
ownState: {
search: null,
},
});
}
};
useEffect(() => {
const firstItem: SelectValue = data[0]
? (groupby.map(col => data[0][col]) as string[])
: null;
if (!isDisabled && defaultToFirstItem && firstItem) {
// initialize to first value if set to default to first item
setValues(firstItem);
} else if (!isDisabled && defaultValue?.length) {
// initialize to saved value
setValues(defaultValue);
}
}, [defaultToFirstItem, defaultValue]);
const handleBlur = () => {
clearSuggestionSearch();
unsetFocusedFilter();
@ -92,27 +179,24 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
});
const handleChange = (value?: SelectValue | number | string) => {
let selectValue: (number | string)[] = ensureIsArray<number | string>(
value,
);
let stateValue: SelectValue | typeof FIRST_VALUE = selectValue.length
? selectValue
: null;
setValues(ensureIsArray(value));
};
if (value === FIRST_VALUE) {
selectValue = forceFirstValue ? [] : firstItem;
stateValue = FIRST_VALUE;
useEffect(() => {
if (isDisabled) {
setValues([]);
}
}, [isDisabled]);
setValues(selectValue);
useEffect(() => {
const emptyFilter =
enableEmptyFilter && !inverseSelection && selectValue?.length === 0;
enableEmptyFilter && !inverseSelection && values?.length === 0;
setDataMask({
dispatchDataMask({
type: 'filterState',
extraFormData: getSelectExtraFormData(
col,
selectValue,
values,
emptyFilter,
inverseSelection,
),
@ -120,33 +204,21 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
// We need to save in state `FIRST_VALUE` as some const and not as REAL value,
// because when FiltersBar check if all filters initialized it compares `defaultValue` with this value
// and because REAL value can be unpredictable for users that have different data for same dashboard we use `FIRST_VALUE`
value: stateValue,
value: values,
},
});
};
}, [col, enableEmptyFilter, inverseSelection, JSON.stringify(values)]);
useEffect(() => {
// For currentValue we need set always `FIRST_VALUE` only if we in config modal for `defaultToFirstItem` mode
handleChange(forceFirstValue ? FIRST_VALUE : filterState.value ?? []);
}, [
JSON.stringify(filterState.value),
defaultToFirstItem,
multiSelect,
enableEmptyFilter,
inverseSelection,
]);
// handle changes coming from application, e.g. "Clear all" button
if (JSON.stringify(values) !== JSON.stringify(filterState.value)) {
handleChange(filterState.value);
}
}, [JSON.stringify(filterState.value)]);
useEffect(() => {
// If we have `defaultToFirstItem` mode it means that default value always `FIRST_VALUE`
handleChange(defaultToFirstItem ? FIRST_VALUE : defaultValue);
}, [
JSON.stringify(defaultValue),
JSON.stringify(firstItem),
defaultToFirstItem,
multiSelect,
enableEmptyFilter,
inverseSelection,
]);
setDataMask(dataMask);
}, [JSON.stringify(dataMask)]);
const placeholderText =
data.length === 0
@ -158,17 +230,18 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
allowClear={!enableEmptyFilter}
// @ts-ignore
value={values}
disabled={forceFirstValue}
disabled={isDisabled}
showSearch={showSearch}
mode={multiSelect ? 'multiple' : undefined}
placeholder={placeholderText}
onSearch={setCurrentSuggestionSearch}
onSearch={searchWrapper}
onSelect={clearSuggestionSearch}
onBlur={handleBlur}
onFocus={setFocusedFilter}
// @ts-ignore
onChange={handleChange}
ref={inputRef}
loading={isRefreshing}
>
{data.map(row => {
const [value] = groupby.map(col => row[col]);

View File

@ -16,10 +16,12 @@
* specific language governing permissions and limitations
* under the License.
*/
import { GenericDataType } from '@superset-ui/core';
import buildQuery from './buildQuery';
import { PluginFilterSelectQueryFormData } from './types';
describe('Select buildQuery', () => {
const formData = {
const formData: PluginFilterSelectQueryFormData = {
datasource: '5__table',
groupby: ['my_col'],
viz_type: 'filter_select',
@ -30,6 +32,7 @@ describe('Select buildQuery', () => {
inverseSelection: false,
multiSelect: false,
defaultToFirstItem: false,
searchAllOptions: false,
height: 100,
width: 100,
};
@ -39,6 +42,7 @@ describe('Select buildQuery', () => {
expect(queryContext.queries.length).toEqual(1);
const [query] = queryContext.queries;
expect(query.groupby).toEqual(['my_col']);
expect(query.filters).toEqual([{ col: 'my_col', op: 'IS NOT NULL' }]);
expect(query.metrics).toEqual([]);
expect(query.apply_fetch_values_predicate).toEqual(true);
expect(query.orderby).toEqual([]);
@ -56,4 +60,30 @@ describe('Select buildQuery', () => {
expect(query.metrics).toEqual(['my_metric']);
expect(query.orderby).toEqual([['my_metric', false]]);
});
it('should add text search parameter to query filter', () => {
const queryContext = buildQuery(formData, {
ownState: {
search: 'abc',
coltypeMap: { my_col: GenericDataType.STRING },
},
});
expect(queryContext.queries.length).toEqual(1);
const [query] = queryContext.queries;
expect(query.filters).toEqual([
{ col: 'my_col', op: 'ILIKE', val: '%abc%' },
]);
});
it('should add numeric search parameter to query filter', () => {
const queryContext = buildQuery(formData, {
ownState: {
search: '123',
coltypeMap: { my_col: GenericDataType.NUMERIC },
},
});
expect(queryContext.queries.length).toEqual(1);
const [query] = queryContext.queries;
expect(query.filters).toEqual([{ col: 'my_col', op: '>=', val: 123 }]);
});
});

View File

@ -16,29 +16,63 @@
* specific language governing permissions and limitations
* under the License.
*/
import { buildQueryContext } from '@superset-ui/core';
import {
buildQueryContext,
GenericDataType,
QueryObject,
QueryObjectFilterClause,
} from '@superset-ui/core';
import { BuildQuery } from '@superset-ui/core/lib/chart/registries/ChartBuildQueryRegistrySingleton';
import { DEFAULT_FORM_DATA, PluginFilterSelectQueryFormData } from './types';
export default function buildQuery(formData: PluginFilterSelectQueryFormData) {
const buildQuery: BuildQuery<PluginFilterSelectQueryFormData> = (
formData: PluginFilterSelectQueryFormData,
options,
) => {
const { search, coltypeMap } = options?.ownState || {};
const { sortAscending, sortMetric } = { ...DEFAULT_FORM_DATA, ...formData };
return buildQueryContext(formData, baseQueryObject => {
const { columns = [], filters = [] } = baseQueryObject;
const extra_filters: QueryObjectFilterClause[] = columns.map(column => {
if (search && coltypeMap[column] === GenericDataType.STRING) {
return {
col: column,
op: 'ILIKE',
val: `%${search}%`,
};
}
if (
search &&
coltypeMap[column] === GenericDataType.NUMERIC &&
!Number.isNaN(Number(search))
) {
// for numeric columns we apply a >= where clause
return {
col: column,
op: '>=',
val: Number(search),
};
}
// if no search is defined, make sure the col value is not null
return { col: column, op: 'IS NOT NULL' };
});
const sortColumns = sortMetric ? [sortMetric] : columns;
return [
const query: QueryObject[] = [
{
...baseQueryObject,
apply_fetch_values_predicate: true,
groupby: columns,
metrics: sortMetric ? [sortMetric] : [],
filters: filters.concat(
columns.map(column => ({ col: column, op: 'IS NOT NULL' })),
),
filters: filters.concat(extra_filters),
orderby:
sortMetric || sortAscending
? sortColumns.map(column => [column, sortAscending])
: [],
},
];
return query;
});
}
};
export default buildQuery;

View File

@ -25,6 +25,7 @@ const {
inverseSelection,
multiSelect,
defaultToFirstItem,
searchAllOptions,
sortAscending,
} = DEFAULT_FORM_DATA;
@ -109,6 +110,23 @@ const config: ControlPanelConfig = {
},
},
],
[
{
name: 'searchAllOptions',
config: {
type: 'CheckboxControl',
renderTrigger: true,
affectsDataMask: true,
label: t('Search all filter options'),
default: searchAllOptions,
description: t(
'By default, each filter loads at most 1000 choices at the initial page load. ' +
'Check this box if you have more than 1000 filter values and want to enable dynamically ' +
'searching that loads filter values as users type (may add stress to your database).',
),
},
},
],
],
},
],

View File

@ -28,6 +28,7 @@ export default class FilterSelectPlugin extends ChartPlugin {
name: t('Select filter'),
description: t('Select filter plugin using AntD'),
behaviors: [Behavior.INTERACTIVE_CHART, Behavior.NATIVE_FILTER],
enableNoResults: false,
thumbnail,
});

View File

@ -31,6 +31,7 @@ export default function transformProps(
behaviors,
appSection,
filterState,
isRefreshing,
} = chartProps;
const newFormData = { ...DEFAULT_FORM_DATA, ...formData };
const {
@ -54,6 +55,7 @@ export default function transformProps(
height,
data,
formData: newFormData,
isRefreshing,
setDataMask,
setFocusedFilter,
unsetFocusedFilter,

View File

@ -29,16 +29,16 @@ import {
import { RefObject } from 'react';
import { PluginFilterHooks, PluginFilterStylesProps } from '../types';
export const FIRST_VALUE = '__FIRST_VALUE__';
export type SelectValue = (number | string)[] | null;
interface PluginFilterSelectCustomizeProps {
defaultValue?: SelectValue | typeof FIRST_VALUE;
defaultValue?: SelectValue;
enableEmptyFilter: boolean;
inverseSelection: boolean;
multiSelect: boolean;
defaultToFirstItem: boolean;
inputRef?: RefObject<HTMLInputElement>;
searchAllOptions: boolean;
sortAscending: boolean;
sortMetric?: string;
}
@ -58,6 +58,7 @@ export type PluginFilterSelectProps = PluginFilterStylesProps & {
appSection: AppSection;
formData: PluginFilterSelectQueryFormData;
filterState: FilterState;
isRefreshing: boolean;
} & PluginFilterHooks;
export const DEFAULT_FORM_DATA: PluginFilterSelectCustomizeProps = {
@ -66,5 +67,6 @@ export const DEFAULT_FORM_DATA: PluginFilterSelectCustomizeProps = {
inverseSelection: false,
defaultToFirstItem: false,
multiSelect: true,
searchAllOptions: false,
sortAscending: true,
};

View File

@ -1239,6 +1239,8 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
where_clause_and.append(col_obj.get_sqla_col() <= eq)
elif op == utils.FilterOperator.LIKE.value:
where_clause_and.append(col_obj.get_sqla_col().like(eq))
elif op == utils.FilterOperator.ILIKE.value:
where_clause_and.append(col_obj.get_sqla_col().ilike(eq))
else:
raise QueryObjectValidationError(
_("Invalid filter operation type: %(op)s", op=op)

View File

@ -206,6 +206,7 @@ class FilterOperator(str, Enum):
GREATER_THAN_OR_EQUALS = ">="
LESS_THAN_OR_EQUALS = "<="
LIKE = "LIKE"
ILIKE = "ILIKE"
IS_NULL = "IS NULL"
IS_NOT_NULL = "IS NOT NULL"
IN = "IN" # pylint: disable=invalid-name