mirror of https://github.com/apache/superset.git
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:
parent
8484ee653f
commit
e9657afe4b
File diff suppressed because it is too large
Load Diff
|
@ -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",
|
||||
|
|
|
@ -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 }}
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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" ->')
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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';
|
||||
}
|
||||
|
|
|
@ -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['=='],
|
||||
|
|
|
@ -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]);
|
||||
|
|
|
@ -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 }]);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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).',
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue