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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -18,31 +18,76 @@
*/ */
import { import {
AppSection, AppSection,
DataMask,
ensureIsArray, ensureIsArray,
ExtraFormData,
GenericDataType, GenericDataType,
JsonObject,
smartDateDetailedFormatter, smartDateDetailedFormatter,
t, t,
tn, tn,
} from '@superset-ui/core'; } 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 { 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 { StyledSelect, Styles } from '../common';
import { getDataRecordFormatter, getSelectExtraFormData } from '../../utils'; import { getDataRecordFormatter, getSelectExtraFormData } from '../../utils';
const { Option } = Select; 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) { export default function PluginFilterSelect(props: PluginFilterSelectProps) {
const { const {
coltypeMap, coltypeMap,
data, data,
filterState,
formData, formData,
height, height,
isRefreshing,
width, width,
setDataMask, setDataMask,
setFocusedFilter, setFocusedFilter,
unsetFocusedFilter, unsetFocusedFilter,
filterState,
appSection, appSection,
} = props; } = props;
const { const {
@ -53,33 +98,75 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
inverseSelection, inverseSelection,
inputRef, inputRef,
defaultToFirstItem, defaultToFirstItem,
searchAllOptions,
} = formData; } = formData;
const forceFirstValue = const isDisabled =
appSection === AppSection.FILTER_CONFIG_MODAL && defaultToFirstItem; appSection === AppSection.FILTER_CONFIG_MODAL && defaultToFirstItem;
const groupby = ensureIsArray<string>(formData.groupby); const groupby = ensureIsArray<string>(formData.groupby);
// Correct initial value for Ant Select // 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` // If we are in config modal we always need show empty select for `defaultToFirstItem`
const [values, setValues] = useState<SelectValue>( const [values, setValues] = useState<SelectValue>(
defaultToFirstItem && appSection !== AppSection.FILTER_CONFIG_MODAL !isDisabled && defaultValue?.length ? defaultValue : [],
? firstItem
: initSelectValue,
); );
const [currentSuggestionSearch, setCurrentSuggestionSearch] = useState(''); 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 = () => { const clearSuggestionSearch = () => {
setCurrentSuggestionSearch(''); 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 = () => { const handleBlur = () => {
clearSuggestionSearch(); clearSuggestionSearch();
unsetFocusedFilter(); unsetFocusedFilter();
@ -92,27 +179,24 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
}); });
const handleChange = (value?: SelectValue | number | string) => { const handleChange = (value?: SelectValue | number | string) => {
let selectValue: (number | string)[] = ensureIsArray<number | string>( setValues(ensureIsArray(value));
value, };
);
let stateValue: SelectValue | typeof FIRST_VALUE = selectValue.length
? selectValue
: null;
if (value === FIRST_VALUE) { useEffect(() => {
selectValue = forceFirstValue ? [] : firstItem; if (isDisabled) {
stateValue = FIRST_VALUE; setValues([]);
} }
}, [isDisabled]);
setValues(selectValue); useEffect(() => {
const emptyFilter = const emptyFilter =
enableEmptyFilter && !inverseSelection && selectValue?.length === 0; enableEmptyFilter && !inverseSelection && values?.length === 0;
setDataMask({ dispatchDataMask({
type: 'filterState',
extraFormData: getSelectExtraFormData( extraFormData: getSelectExtraFormData(
col, col,
selectValue, values,
emptyFilter, emptyFilter,
inverseSelection, 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, // 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 // 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` // 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(() => { useEffect(() => {
// For currentValue we need set always `FIRST_VALUE` only if we in config modal for `defaultToFirstItem` mode // handle changes coming from application, e.g. "Clear all" button
handleChange(forceFirstValue ? FIRST_VALUE : filterState.value ?? []); if (JSON.stringify(values) !== JSON.stringify(filterState.value)) {
}, [ handleChange(filterState.value);
JSON.stringify(filterState.value), }
defaultToFirstItem, }, [JSON.stringify(filterState.value)]);
multiSelect,
enableEmptyFilter,
inverseSelection,
]);
useEffect(() => { useEffect(() => {
// If we have `defaultToFirstItem` mode it means that default value always `FIRST_VALUE` setDataMask(dataMask);
handleChange(defaultToFirstItem ? FIRST_VALUE : defaultValue); }, [JSON.stringify(dataMask)]);
}, [
JSON.stringify(defaultValue),
JSON.stringify(firstItem),
defaultToFirstItem,
multiSelect,
enableEmptyFilter,
inverseSelection,
]);
const placeholderText = const placeholderText =
data.length === 0 data.length === 0
@ -158,17 +230,18 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
allowClear={!enableEmptyFilter} allowClear={!enableEmptyFilter}
// @ts-ignore // @ts-ignore
value={values} value={values}
disabled={forceFirstValue} disabled={isDisabled}
showSearch={showSearch} showSearch={showSearch}
mode={multiSelect ? 'multiple' : undefined} mode={multiSelect ? 'multiple' : undefined}
placeholder={placeholderText} placeholder={placeholderText}
onSearch={setCurrentSuggestionSearch} onSearch={searchWrapper}
onSelect={clearSuggestionSearch} onSelect={clearSuggestionSearch}
onBlur={handleBlur} onBlur={handleBlur}
onFocus={setFocusedFilter} onFocus={setFocusedFilter}
// @ts-ignore // @ts-ignore
onChange={handleChange} onChange={handleChange}
ref={inputRef} ref={inputRef}
loading={isRefreshing}
> >
{data.map(row => { {data.map(row => {
const [value] = groupby.map(col => row[col]); const [value] = groupby.map(col => row[col]);

View File

@ -16,10 +16,12 @@
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
import { GenericDataType } from '@superset-ui/core';
import buildQuery from './buildQuery'; import buildQuery from './buildQuery';
import { PluginFilterSelectQueryFormData } from './types';
describe('Select buildQuery', () => { describe('Select buildQuery', () => {
const formData = { const formData: PluginFilterSelectQueryFormData = {
datasource: '5__table', datasource: '5__table',
groupby: ['my_col'], groupby: ['my_col'],
viz_type: 'filter_select', viz_type: 'filter_select',
@ -30,6 +32,7 @@ describe('Select buildQuery', () => {
inverseSelection: false, inverseSelection: false,
multiSelect: false, multiSelect: false,
defaultToFirstItem: false, defaultToFirstItem: false,
searchAllOptions: false,
height: 100, height: 100,
width: 100, width: 100,
}; };
@ -39,6 +42,7 @@ describe('Select buildQuery', () => {
expect(queryContext.queries.length).toEqual(1); expect(queryContext.queries.length).toEqual(1);
const [query] = queryContext.queries; const [query] = queryContext.queries;
expect(query.groupby).toEqual(['my_col']); expect(query.groupby).toEqual(['my_col']);
expect(query.filters).toEqual([{ col: 'my_col', op: 'IS NOT NULL' }]);
expect(query.metrics).toEqual([]); expect(query.metrics).toEqual([]);
expect(query.apply_fetch_values_predicate).toEqual(true); expect(query.apply_fetch_values_predicate).toEqual(true);
expect(query.orderby).toEqual([]); expect(query.orderby).toEqual([]);
@ -56,4 +60,30 @@ describe('Select buildQuery', () => {
expect(query.metrics).toEqual(['my_metric']); expect(query.metrics).toEqual(['my_metric']);
expect(query.orderby).toEqual([['my_metric', false]]); 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 * specific language governing permissions and limitations
* under the License. * 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'; 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 }; const { sortAscending, sortMetric } = { ...DEFAULT_FORM_DATA, ...formData };
return buildQueryContext(formData, baseQueryObject => { return buildQueryContext(formData, baseQueryObject => {
const { columns = [], filters = [] } = 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; const sortColumns = sortMetric ? [sortMetric] : columns;
return [ const query: QueryObject[] = [
{ {
...baseQueryObject, ...baseQueryObject,
apply_fetch_values_predicate: true, apply_fetch_values_predicate: true,
groupby: columns, groupby: columns,
metrics: sortMetric ? [sortMetric] : [], metrics: sortMetric ? [sortMetric] : [],
filters: filters.concat( filters: filters.concat(extra_filters),
columns.map(column => ({ col: column, op: 'IS NOT NULL' })),
),
orderby: orderby:
sortMetric || sortAscending sortMetric || sortAscending
? sortColumns.map(column => [column, sortAscending]) ? sortColumns.map(column => [column, sortAscending])
: [], : [],
}, },
]; ];
return query;
}); });
} };
export default buildQuery;

View File

@ -25,6 +25,7 @@ const {
inverseSelection, inverseSelection,
multiSelect, multiSelect,
defaultToFirstItem, defaultToFirstItem,
searchAllOptions,
sortAscending, sortAscending,
} = DEFAULT_FORM_DATA; } = 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'), name: t('Select filter'),
description: t('Select filter plugin using AntD'), description: t('Select filter plugin using AntD'),
behaviors: [Behavior.INTERACTIVE_CHART, Behavior.NATIVE_FILTER], behaviors: [Behavior.INTERACTIVE_CHART, Behavior.NATIVE_FILTER],
enableNoResults: false,
thumbnail, thumbnail,
}); });

View File

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

View File

@ -29,16 +29,16 @@ import {
import { RefObject } from 'react'; import { RefObject } from 'react';
import { PluginFilterHooks, PluginFilterStylesProps } from '../types'; import { PluginFilterHooks, PluginFilterStylesProps } from '../types';
export const FIRST_VALUE = '__FIRST_VALUE__';
export type SelectValue = (number | string)[] | null; export type SelectValue = (number | string)[] | null;
interface PluginFilterSelectCustomizeProps { interface PluginFilterSelectCustomizeProps {
defaultValue?: SelectValue | typeof FIRST_VALUE; defaultValue?: SelectValue;
enableEmptyFilter: boolean; enableEmptyFilter: boolean;
inverseSelection: boolean; inverseSelection: boolean;
multiSelect: boolean; multiSelect: boolean;
defaultToFirstItem: boolean; defaultToFirstItem: boolean;
inputRef?: RefObject<HTMLInputElement>; inputRef?: RefObject<HTMLInputElement>;
searchAllOptions: boolean;
sortAscending: boolean; sortAscending: boolean;
sortMetric?: string; sortMetric?: string;
} }
@ -58,6 +58,7 @@ export type PluginFilterSelectProps = PluginFilterStylesProps & {
appSection: AppSection; appSection: AppSection;
formData: PluginFilterSelectQueryFormData; formData: PluginFilterSelectQueryFormData;
filterState: FilterState; filterState: FilterState;
isRefreshing: boolean;
} & PluginFilterHooks; } & PluginFilterHooks;
export const DEFAULT_FORM_DATA: PluginFilterSelectCustomizeProps = { export const DEFAULT_FORM_DATA: PluginFilterSelectCustomizeProps = {
@ -66,5 +67,6 @@ export const DEFAULT_FORM_DATA: PluginFilterSelectCustomizeProps = {
inverseSelection: false, inverseSelection: false,
defaultToFirstItem: false, defaultToFirstItem: false,
multiSelect: true, multiSelect: true,
searchAllOptions: false,
sortAscending: true, 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) where_clause_and.append(col_obj.get_sqla_col() <= eq)
elif op == utils.FilterOperator.LIKE.value: elif op == utils.FilterOperator.LIKE.value:
where_clause_and.append(col_obj.get_sqla_col().like(eq)) 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: else:
raise QueryObjectValidationError( raise QueryObjectValidationError(
_("Invalid filter operation type: %(op)s", op=op) _("Invalid filter operation type: %(op)s", op=op)

View File

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