diff --git a/superset-frontend/spec/javascripts/explore/components/FilterBoxItemControl_spec.jsx b/superset-frontend/spec/javascripts/explore/components/FilterBoxItemControl_spec.jsx index c1a38ecbf6..0a7a392707 100644 --- a/superset-frontend/spec/javascripts/explore/components/FilterBoxItemControl_spec.jsx +++ b/superset-frontend/spec/javascripts/explore/components/FilterBoxItemControl_spec.jsx @@ -50,7 +50,7 @@ describe('FilterBoxItemControl', () => { it('renderForms does the job', () => { 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', () => { diff --git a/superset-frontend/src/explore/components/controls/FilterBoxItemControl.jsx b/superset-frontend/src/explore/components/controls/FilterBoxItemControl.jsx index e7506e6b95..9b44d3c798 100644 --- a/superset-frontend/src/explore/components/controls/FilterBoxItemControl.jsx +++ b/superset-frontend/src/explore/components/controls/FilterBoxItemControl.jsx @@ -53,6 +53,7 @@ const propTypes = { multiple: PropTypes.bool, column: PropTypes.string, metric: PropTypes.string, + searchAllOptions: PropTypes.bool, defaultValue: PropTypes.string, }; @@ -61,6 +62,7 @@ const defaultProps = { asc: true, clearable: true, multiple: true, + searchAllOptions: true, }; const STYLE_WIDTH = { width: 350 }; @@ -68,8 +70,24 @@ const STYLE_WIDTH = { width: 350 }; export default class FilterBoxItemControl extends React.Component { constructor(props) { super(props); - const { column, metric, asc, clearable, multiple, defaultValue } = props; - const state = { column, metric, asc, clearable, multiple, defaultValue }; + const { + column, + metric, + asc, + clearable, + multiple, + searchAllOptions, + defaultValue, + } = props; + const state = { + column, + metric, + asc, + clearable, + multiple, + searchAllOptions, + defaultValue, + }; this.state = state; this.onChange = this.onChange.bind(this); this.onControlChange = this.onControlChange.bind(this); @@ -202,6 +220,26 @@ export default class FilterBoxItemControl extends React.Component { /> } /> + + this.onControlChange( + FILTER_CONFIG_ATTRIBUTES.SEARCH_ALL_OPTIONS, + v, + ) + } + /> + } + /> x.metric), + ); + return this.maxValueCache[key]; + } + clickApply() { const { selectedValues } = this.state; this.setState({ hasChanged: false }, () => { this.props.onChange(selectedValues, false); }); } - changeFilter(filter, options) { const fltr = TIME_FILTER_MAP[filter] || filter; 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() { const { showDateFilter, chartId } = this.props; const label = TIME_FILTER_LABELS.time_range; @@ -229,6 +315,8 @@ class FilterBox extends React.Component { renderSelect(filterConfig) { const { filtersChoices } = this.props; const { selectedValues } = this.state; + this.debouncerCache = {}; + this.maxValueCache = {}; // Add created options to filtersChoices, even though it doesn't exist, // 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, ) .forEach(key => { - const choices = filtersChoices[key] || []; + const choices = filtersChoices[key] || (filtersChoices[key] = []); const choiceIds = new Set(choices.map(f => f.id)); const selectedValuesForKey = Array.isArray(selectedValues[key]) ? selectedValues[key] @@ -255,7 +343,6 @@ class FilterBox extends React.Component { }); const { key, label } = filterConfig; const data = filtersChoices[key] || []; - const max = Math.max(...data.map(d => d.metric)); let value = selectedValues[key] || null; // Assign default value if required @@ -273,20 +360,15 @@ class FilterBox extends React.Component { return ( 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 }; - })} + options={this.transformOptions(data)} onChange={newValue => { // avoid excessive re-renders if (newValue !== value) { @@ -297,7 +379,12 @@ class FilterBox extends React.Component { onMenuOpen={() => this.onFilterMenuOpen(key)} onBlur={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')} /> ); diff --git a/superset-frontend/src/visualizations/FilterBox/transformProps.js b/superset-frontend/src/visualizations/FilterBox/transformProps.js index f00918a0bb..7c845b3601 100644 --- a/superset-frontend/src/visualizations/FilterBox/transformProps.js +++ b/superset-frontend/src/visualizations/FilterBox/transformProps.js @@ -26,6 +26,7 @@ export default function transformProps(chartProps) { initialValues, queryData, rawDatasource, + rawFormData, } = chartProps; const { onAddFilter = NOOP, @@ -65,5 +66,7 @@ export default function transformProps(chartProps) { showDruidTimeOrigin, showSqlaTimeColumn, showSqlaTimeGrain: showSqlaTimeGranularity, + // the original form data, needed for async select options + rawFormData, }; }