mirror of https://github.com/apache/superset.git
feat: Typeahead searchable filter_box for dashboard (#10210)
* [WIP] Typeahead dashboard filter_box * Make it work * add config option for async filter_box * enable for > 1000 options only Co-authored-by: Jesse Yang <jesse.yang@airbnb.com>
This commit is contained in:
parent
878dbcda3f
commit
f849103374
|
@ -50,7 +50,7 @@ describe('FilterBoxItemControl', () => {
|
||||||
|
|
||||||
it('renderForms does the job', () => {
|
it('renderForms does the job', () => {
|
||||||
const popover = shallow(inst.renderForm());
|
const popover = shallow(inst.renderForm());
|
||||||
expect(popover.find(FormRow)).toHaveLength(7);
|
expect(popover.find(FormRow)).toHaveLength(8);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('convert type for single value filter_box', () => {
|
it('convert type for single value filter_box', () => {
|
||||||
|
|
|
@ -53,6 +53,7 @@ const propTypes = {
|
||||||
multiple: PropTypes.bool,
|
multiple: PropTypes.bool,
|
||||||
column: PropTypes.string,
|
column: PropTypes.string,
|
||||||
metric: PropTypes.string,
|
metric: PropTypes.string,
|
||||||
|
searchAllOptions: PropTypes.bool,
|
||||||
defaultValue: PropTypes.string,
|
defaultValue: PropTypes.string,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -61,6 +62,7 @@ const defaultProps = {
|
||||||
asc: true,
|
asc: true,
|
||||||
clearable: true,
|
clearable: true,
|
||||||
multiple: true,
|
multiple: true,
|
||||||
|
searchAllOptions: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const STYLE_WIDTH = { width: 350 };
|
const STYLE_WIDTH = { width: 350 };
|
||||||
|
@ -68,8 +70,24 @@ const STYLE_WIDTH = { width: 350 };
|
||||||
export default class FilterBoxItemControl extends React.Component {
|
export default class FilterBoxItemControl extends React.Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
const { column, metric, asc, clearable, multiple, defaultValue } = props;
|
const {
|
||||||
const state = { column, metric, asc, clearable, multiple, defaultValue };
|
column,
|
||||||
|
metric,
|
||||||
|
asc,
|
||||||
|
clearable,
|
||||||
|
multiple,
|
||||||
|
searchAllOptions,
|
||||||
|
defaultValue,
|
||||||
|
} = props;
|
||||||
|
const state = {
|
||||||
|
column,
|
||||||
|
metric,
|
||||||
|
asc,
|
||||||
|
clearable,
|
||||||
|
multiple,
|
||||||
|
searchAllOptions,
|
||||||
|
defaultValue,
|
||||||
|
};
|
||||||
this.state = state;
|
this.state = state;
|
||||||
this.onChange = this.onChange.bind(this);
|
this.onChange = this.onChange.bind(this);
|
||||||
this.onControlChange = this.onControlChange.bind(this);
|
this.onControlChange = this.onControlChange.bind(this);
|
||||||
|
@ -202,6 +220,26 @@ export default class FilterBoxItemControl extends React.Component {
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<FormRow
|
||||||
|
label={t('Search All Filter Options')}
|
||||||
|
tooltip={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).',
|
||||||
|
)}
|
||||||
|
isCheckbox
|
||||||
|
control={
|
||||||
|
<CheckboxControl
|
||||||
|
value={this.state.searchAllOptions}
|
||||||
|
onChange={v =>
|
||||||
|
this.onControlChange(
|
||||||
|
FILTER_CONFIG_ATTRIBUTES.SEARCH_ALL_OPTIONS,
|
||||||
|
v,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<FormRow
|
<FormRow
|
||||||
label={t('Required')}
|
label={t('Required')}
|
||||||
tooltip={t('User must select a value for this filter')}
|
tooltip={t('User must select a value for this filter')}
|
||||||
|
|
|
@ -84,4 +84,8 @@ export const TIME_FILTER_LABELS = {
|
||||||
export const FILTER_CONFIG_ATTRIBUTES = {
|
export const FILTER_CONFIG_ATTRIBUTES = {
|
||||||
DEFAULT_VALUE: 'defaultValue',
|
DEFAULT_VALUE: 'defaultValue',
|
||||||
MULTIPLE: 'multiple',
|
MULTIPLE: 'multiple',
|
||||||
|
SEARCH_ALL_OPTIONS: 'searchAllOptions',
|
||||||
|
CLEARABLE: 'clearable',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const FILTER_OPTIONS_LIMIT = 1000;
|
||||||
|
|
|
@ -18,19 +18,24 @@
|
||||||
*/
|
*/
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { CreatableSelect } from 'src/components/Select';
|
import { debounce } from 'lodash';
|
||||||
|
import { max as d3Max } from 'd3-array';
|
||||||
|
import { AsyncCreatableSelect, CreatableSelect } from 'src/components/Select';
|
||||||
import { Button } from 'react-bootstrap';
|
import { Button } from 'react-bootstrap';
|
||||||
import { t } from '@superset-ui/translation';
|
import { t } from '@superset-ui/translation';
|
||||||
|
import { SupersetClient } from '@superset-ui/connection';
|
||||||
|
|
||||||
import DateFilterControl from '../../explore/components/controls/DateFilterControl';
|
import DateFilterControl from '../../explore/components/controls/DateFilterControl';
|
||||||
import ControlRow from '../../explore/components/ControlRow';
|
import ControlRow from '../../explore/components/ControlRow';
|
||||||
import Control from '../../explore/components/Control';
|
import Control from '../../explore/components/Control';
|
||||||
import controls from '../../explore/controls';
|
import controls from '../../explore/controls';
|
||||||
|
import { getExploreUrl } from '../../explore/exploreUtils';
|
||||||
import OnPasteSelect from '../../components/Select/OnPasteSelect';
|
import OnPasteSelect from '../../components/Select/OnPasteSelect';
|
||||||
import { getDashboardFilterKey } from '../../dashboard/util/getDashboardFilterKey';
|
import { getDashboardFilterKey } from '../../dashboard/util/getDashboardFilterKey';
|
||||||
import { getFilterColorMap } from '../../dashboard/util/dashboardFiltersColorMap';
|
import { getFilterColorMap } from '../../dashboard/util/dashboardFiltersColorMap';
|
||||||
import {
|
import {
|
||||||
FILTER_CONFIG_ATTRIBUTES,
|
FILTER_CONFIG_ATTRIBUTES,
|
||||||
|
FILTER_OPTIONS_LIMIT,
|
||||||
TIME_FILTER_LABELS,
|
TIME_FILTER_LABELS,
|
||||||
} from '../../explore/constants';
|
} from '../../explore/constants';
|
||||||
import FilterBadgeIcon from '../../components/FilterBadgeIcon';
|
import FilterBadgeIcon from '../../components/FilterBadgeIcon';
|
||||||
|
@ -100,6 +105,8 @@ class FilterBox extends React.Component {
|
||||||
// this flag is used by non-instant filter, to make the apply button enabled/disabled
|
// this flag is used by non-instant filter, to make the apply button enabled/disabled
|
||||||
hasChanged: false,
|
hasChanged: false,
|
||||||
};
|
};
|
||||||
|
this.debouncerCache = {};
|
||||||
|
this.maxValueCache = {};
|
||||||
this.changeFilter = this.changeFilter.bind(this);
|
this.changeFilter = this.changeFilter.bind(this);
|
||||||
this.onFilterMenuOpen = this.onFilterMenuOpen.bind(this);
|
this.onFilterMenuOpen = this.onFilterMenuOpen.bind(this);
|
||||||
this.onOpenDateFilterControl = this.onOpenDateFilterControl.bind(this);
|
this.onOpenDateFilterControl = this.onOpenDateFilterControl.bind(this);
|
||||||
|
@ -131,13 +138,23 @@ class FilterBox extends React.Component {
|
||||||
return mapFunc ? { ...control, ...mapFunc(this.props) } : control;
|
return mapFunc ? { ...control, ...mapFunc(this.props) } : control;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get known max value of a column
|
||||||
|
*/
|
||||||
|
getKnownMax(key, choices) {
|
||||||
|
this.maxValueCache[key] = Math.max(
|
||||||
|
this.maxValueCache[key] || 0,
|
||||||
|
d3Max(choices || this.props.filtersChoices[key] || [], x => x.metric),
|
||||||
|
);
|
||||||
|
return this.maxValueCache[key];
|
||||||
|
}
|
||||||
|
|
||||||
clickApply() {
|
clickApply() {
|
||||||
const { selectedValues } = this.state;
|
const { selectedValues } = this.state;
|
||||||
this.setState({ hasChanged: false }, () => {
|
this.setState({ hasChanged: false }, () => {
|
||||||
this.props.onChange(selectedValues, false);
|
this.props.onChange(selectedValues, false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
changeFilter(filter, options) {
|
changeFilter(filter, options) {
|
||||||
const fltr = TIME_FILTER_MAP[filter] || filter;
|
const fltr = TIME_FILTER_MAP[filter] || filter;
|
||||||
let vals = null;
|
let vals = null;
|
||||||
|
@ -162,6 +179,75 @@ class FilterBox extends React.Component {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a debounce function that loads options for a specific column
|
||||||
|
*/
|
||||||
|
debounceLoadOptions(key) {
|
||||||
|
if (!(key in this.debouncerCache)) {
|
||||||
|
this.debouncerCache[key] = debounce((input, callback) => {
|
||||||
|
this.loadOptions(key, input).then(callback);
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
return this.debouncerCache[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform select options, add bar background
|
||||||
|
*/
|
||||||
|
transformOptions(options, max) {
|
||||||
|
const maxValue = max === undefined ? d3Max(options, x => x.metric) : max;
|
||||||
|
return options.map(opt => {
|
||||||
|
const perc = Math.round((opt.metric / maxValue) * 100);
|
||||||
|
const color = 'lightgrey';
|
||||||
|
const backgroundImage = `linear-gradient(to right, ${color}, ${color} ${perc}%, rgba(0,0,0,0) ${perc}%`;
|
||||||
|
const style = { backgroundImage };
|
||||||
|
return { value: opt.id, label: opt.id, style };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadOptions(key, inputValue = '') {
|
||||||
|
const input = inputValue.toLowerCase();
|
||||||
|
const sortAsc = this.props.filtersFields.find(x => x.key === key).asc;
|
||||||
|
const formData = {
|
||||||
|
...this.props.rawFormData,
|
||||||
|
adhoc_filters: inputValue
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
clause: 'WHERE',
|
||||||
|
comparator: null,
|
||||||
|
expressionType: 'SQL',
|
||||||
|
// TODO: Evaluate SQL Injection risk
|
||||||
|
sqlExpression: `lower(${key}) like '%${input}%'`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { json } = await SupersetClient.get({
|
||||||
|
url: getExploreUrl({
|
||||||
|
formData,
|
||||||
|
endpointType: 'json',
|
||||||
|
method: 'GET',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const options = (json?.data?.[key] || []).filter(x => x.id);
|
||||||
|
if (!options || options.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (input) {
|
||||||
|
// sort those starts with search query to front
|
||||||
|
options.sort((a, b) => {
|
||||||
|
const labelA = a.id.toLowerCase();
|
||||||
|
const labelB = b.id.toLowerCase();
|
||||||
|
const textOrder = labelB.startsWith(input) - labelA.startsWith(input);
|
||||||
|
return textOrder === 0
|
||||||
|
? (a.metric - b.metric) * (sortAsc ? 1 : -1)
|
||||||
|
: textOrder;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.transformOptions(options, this.getKnownMax(key, options));
|
||||||
|
}
|
||||||
|
|
||||||
renderDateFilter() {
|
renderDateFilter() {
|
||||||
const { showDateFilter, chartId } = this.props;
|
const { showDateFilter, chartId } = this.props;
|
||||||
const label = TIME_FILTER_LABELS.time_range;
|
const label = TIME_FILTER_LABELS.time_range;
|
||||||
|
@ -229,6 +315,8 @@ class FilterBox extends React.Component {
|
||||||
renderSelect(filterConfig) {
|
renderSelect(filterConfig) {
|
||||||
const { filtersChoices } = this.props;
|
const { filtersChoices } = this.props;
|
||||||
const { selectedValues } = this.state;
|
const { selectedValues } = this.state;
|
||||||
|
this.debouncerCache = {};
|
||||||
|
this.maxValueCache = {};
|
||||||
|
|
||||||
// Add created options to filtersChoices, even though it doesn't exist,
|
// Add created options to filtersChoices, even though it doesn't exist,
|
||||||
// or these options will exist in query sql but invisible to end user.
|
// or these options will exist in query sql but invisible to end user.
|
||||||
|
@ -237,7 +325,7 @@ class FilterBox extends React.Component {
|
||||||
key => selectedValues.hasOwnProperty(key) && key in filtersChoices,
|
key => selectedValues.hasOwnProperty(key) && key in filtersChoices,
|
||||||
)
|
)
|
||||||
.forEach(key => {
|
.forEach(key => {
|
||||||
const choices = filtersChoices[key] || [];
|
const choices = filtersChoices[key] || (filtersChoices[key] = []);
|
||||||
const choiceIds = new Set(choices.map(f => f.id));
|
const choiceIds = new Set(choices.map(f => f.id));
|
||||||
const selectedValuesForKey = Array.isArray(selectedValues[key])
|
const selectedValuesForKey = Array.isArray(selectedValues[key])
|
||||||
? selectedValues[key]
|
? selectedValues[key]
|
||||||
|
@ -255,7 +343,6 @@ class FilterBox extends React.Component {
|
||||||
});
|
});
|
||||||
const { key, label } = filterConfig;
|
const { key, label } = filterConfig;
|
||||||
const data = filtersChoices[key] || [];
|
const data = filtersChoices[key] || [];
|
||||||
const max = Math.max(...data.map(d => d.metric));
|
|
||||||
let value = selectedValues[key] || null;
|
let value = selectedValues[key] || null;
|
||||||
|
|
||||||
// Assign default value if required
|
// Assign default value if required
|
||||||
|
@ -273,20 +360,15 @@ class FilterBox extends React.Component {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OnPasteSelect
|
<OnPasteSelect
|
||||||
|
cacheOptions
|
||||||
|
loadOptions={this.debounceLoadOptions(key)}
|
||||||
|
defaultOptions={this.transformOptions(data)}
|
||||||
key={key}
|
key={key}
|
||||||
placeholder={t('Select [%s]', label)}
|
placeholder={t('Type or Select [%s]', label)}
|
||||||
isMulti={filterConfig[FILTER_CONFIG_ATTRIBUTES.MULTIPLE]}
|
isMulti={filterConfig[FILTER_CONFIG_ATTRIBUTES.MULTIPLE]}
|
||||||
isClearable={filterConfig.clearable}
|
isClearable={filterConfig[FILTER_CONFIG_ATTRIBUTES.CLEARABLE]}
|
||||||
value={value}
|
value={value}
|
||||||
options={data
|
options={this.transformOptions(data)}
|
||||||
.filter(opt => opt.id !== null)
|
|
||||||
.map(opt => {
|
|
||||||
const perc = Math.round((opt.metric / max) * 100);
|
|
||||||
const color = 'lightgrey';
|
|
||||||
const backgroundImage = `linear-gradient(to right, ${color}, ${color} ${perc}%, rgba(0,0,0,0) ${perc}%`;
|
|
||||||
const style = { backgroundImage };
|
|
||||||
return { value: opt.id, label: opt.id, style };
|
|
||||||
})}
|
|
||||||
onChange={newValue => {
|
onChange={newValue => {
|
||||||
// avoid excessive re-renders
|
// avoid excessive re-renders
|
||||||
if (newValue !== value) {
|
if (newValue !== value) {
|
||||||
|
@ -297,7 +379,12 @@ class FilterBox extends React.Component {
|
||||||
onMenuOpen={() => this.onFilterMenuOpen(key)}
|
onMenuOpen={() => this.onFilterMenuOpen(key)}
|
||||||
onBlur={this.onFilterMenuClose}
|
onBlur={this.onFilterMenuClose}
|
||||||
onMenuClose={this.onFilterMenuClose}
|
onMenuClose={this.onFilterMenuClose}
|
||||||
selectWrap={CreatableSelect}
|
selectWrap={
|
||||||
|
[FILTER_CONFIG_ATTRIBUTES.SEARCH_ALL_OPTIONS] &&
|
||||||
|
data.length >= FILTER_OPTIONS_LIMIT
|
||||||
|
? AsyncCreatableSelect
|
||||||
|
: CreatableSelect
|
||||||
|
}
|
||||||
noResultsText={t('No results found')}
|
noResultsText={t('No results found')}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
@ -26,6 +26,7 @@ export default function transformProps(chartProps) {
|
||||||
initialValues,
|
initialValues,
|
||||||
queryData,
|
queryData,
|
||||||
rawDatasource,
|
rawDatasource,
|
||||||
|
rawFormData,
|
||||||
} = chartProps;
|
} = chartProps;
|
||||||
const {
|
const {
|
||||||
onAddFilter = NOOP,
|
onAddFilter = NOOP,
|
||||||
|
@ -65,5 +66,7 @@ export default function transformProps(chartProps) {
|
||||||
showDruidTimeOrigin,
|
showDruidTimeOrigin,
|
||||||
showSqlaTimeColumn,
|
showSqlaTimeColumn,
|
||||||
showSqlaTimeGrain: showSqlaTimeGranularity,
|
showSqlaTimeGrain: showSqlaTimeGranularity,
|
||||||
|
// the original form data, needed for async select options
|
||||||
|
rawFormData,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue