blob: 23f6cf9e02ca5e2506695c879717b58de37af380 [file] [log] [blame]
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { NativeSelect as Select } from 'src/components/Select';
import { Input } from 'src/common/components';
import { t, SupersetClient, styled } from '@superset-ui/core';
import adhocMetricType from 'src/explore/components/controls/MetricControl/adhocMetricType';
import {
OPERATORS,
OPERATORS_OPTIONS,
TABLE_ONLY_OPERATORS,
DRUID_ONLY_OPERATORS,
HAVING_OPERATORS,
MULTI_OPERATORS,
CUSTOM_OPERATORS,
DISABLE_INPUT_OPERATORS,
} from 'src/explore/constants';
import FilterDefinitionOption from 'src/explore/components/controls/MetricControl/FilterDefinitionOption';
import AdhocFilter, {
EXPRESSION_TYPES,
CLAUSES,
} from 'src/explore/components/controls/FilterControl/AdhocFilter';
import columnType from 'src/explore/components/controls/FilterControl/columnType';
import Icons from 'src/components/Icons';
const SelectWithLabel = styled(Select)`
.ant-select-selector {
margin-bottom: ${({ theme }) => theme.gridUnit * 4}px;
}
.ant-select-selector::after {
content: '${({ labelText }) => labelText || '\\A0'}';
display: inline-block;
white-space: nowrap;
color: ${({ theme }) => theme.colors.grayscale.light1};
width: max-content;
}
`;
const propTypes = {
adhocFilter: PropTypes.instanceOf(AdhocFilter).isRequired,
onChange: PropTypes.func.isRequired,
options: PropTypes.arrayOf(
PropTypes.oneOfType([
columnType,
PropTypes.shape({ saved_metric_name: PropTypes.string.isRequired }),
adhocMetricType,
]),
).isRequired,
onHeightChange: PropTypes.func.isRequired,
datasource: PropTypes.object,
partitionColumn: PropTypes.string,
popoverRef: PropTypes.object,
};
const defaultProps = {
datasource: {},
};
function translateOperator(operator) {
if (operator === OPERATORS['==']) {
return 'equals';
}
if (operator === OPERATORS['!=']) {
return 'not equal to';
}
if (operator === OPERATORS.LIKE) {
return 'LIKE';
}
if (operator === OPERATORS.ILIKE) {
return 'LIKE (case insensitive)';
}
if (operator === OPERATORS['LATEST PARTITION']) {
return 'use latest_partition template';
}
return operator;
}
export default class AdhocFilterEditPopoverSimpleTabContent extends React.Component {
constructor(props) {
super(props);
this.onSubjectChange = this.onSubjectChange.bind(this);
this.onOperatorChange = this.onOperatorChange.bind(this);
this.onComparatorChange = this.onComparatorChange.bind(this);
this.onInputComparatorChange = this.onInputComparatorChange.bind(this);
this.isOperatorRelevant = this.isOperatorRelevant.bind(this);
this.refreshComparatorSuggestions = this.refreshComparatorSuggestions.bind(
this,
);
this.clearSuggestionSearch = this.clearSuggestionSearch.bind(this);
this.state = {
suggestions: [],
abortActiveRequest: null,
currentSuggestionSearch: '',
};
this.selectProps = {
name: 'select-column',
showSearch: true,
};
}
UNSAFE_componentWillMount() {
this.refreshComparatorSuggestions();
}
componentDidUpdate(prevProps) {
if (prevProps.adhocFilter.subject !== this.props.adhocFilter.subject) {
this.refreshComparatorSuggestions();
}
}
onSubjectChange(id) {
const option = this.props.options.find(
option => option.id === id || option.optionName === id,
);
let subject;
let clause;
// infer the new clause based on what subject was selected.
if (option && option.column_name) {
subject = option.column_name;
clause = CLAUSES.WHERE;
} else if (option && (option.saved_metric_name || option.label)) {
subject = option.saved_metric_name || option.label;
clause = CLAUSES.HAVING;
}
const { operator } = this.props.adhocFilter;
this.props.onChange(
this.props.adhocFilter.duplicateWith({
subject,
clause,
operator:
operator && this.isOperatorRelevant(operator, subject)
? operator
: null,
expressionType: EXPRESSION_TYPES.SIMPLE,
}),
);
}
onOperatorChange(operator) {
const currentComparator = this.props.adhocFilter.comparator;
let newComparator;
// convert between list of comparators and individual comparators
// (e.g. `in ('North America', 'Africa')` to `== 'North America'`)
if (MULTI_OPERATORS.has(operator)) {
newComparator = Array.isArray(currentComparator)
? currentComparator
: [currentComparator].filter(element => element);
} else {
newComparator = Array.isArray(currentComparator)
? currentComparator[0]
: currentComparator;
}
if (operator && CUSTOM_OPERATORS.has(operator)) {
this.props.onChange(
this.props.adhocFilter.duplicateWith({
subject: this.props.adhocFilter.subject,
clause: CLAUSES.WHERE,
operator,
expressionType: EXPRESSION_TYPES.SQL,
datasource: this.props.datasource,
}),
);
} else {
this.props.onChange(
this.props.adhocFilter.duplicateWith({
operator,
comparator: newComparator,
expressionType: EXPRESSION_TYPES.SIMPLE,
}),
);
}
}
onInputComparatorChange(event) {
this.onComparatorChange(event.target.value);
}
onComparatorChange(comparator) {
this.props.onChange(
this.props.adhocFilter.duplicateWith({
comparator,
expressionType: EXPRESSION_TYPES.SIMPLE,
}),
);
}
refreshComparatorSuggestions() {
const { datasource } = this.props;
const col = this.props.adhocFilter.subject;
const having = this.props.adhocFilter.clause === CLAUSES.HAVING;
if (col && datasource && datasource.filter_select && !having) {
if (this.state.abortActiveRequest) {
this.state.abortActiveRequest();
}
const controller = new AbortController();
const { signal } = controller;
this.setState({ abortActiveRequest: controller.abort, loading: true });
SupersetClient.get({
signal,
endpoint: `/superset/filter/${datasource.type}/${datasource.id}/${col}/`,
})
.then(({ json }) => {
this.setState(() => ({
suggestions: json,
abortActiveRequest: null,
loading: false,
}));
})
.catch(() => {
this.setState(() => ({
suggestions: [],
abortActiveRequest: null,
loading: false,
}));
});
}
}
isOperatorRelevant(operator, subject) {
const column = this.props.datasource.columns?.find(
col => col.column_name === subject,
);
const isColumnBoolean =
!!column && (column.type === 'BOOL' || column.type === 'BOOLEAN');
const isColumnNumber = !!column && column.type === 'INT';
const isColumnFunction = !!column && !!column.expression;
if (operator && CUSTOM_OPERATORS.has(operator)) {
const { partitionColumn } = this.props;
return partitionColumn && subject && subject === partitionColumn;
}
if (
operator === OPERATORS['IS TRUE'] ||
operator === OPERATORS['IS FALSE']
) {
return isColumnBoolean || isColumnNumber || isColumnFunction;
}
if (isColumnBoolean) {
return (
operator === OPERATORS['IS NULL'] ||
operator === OPERATORS['IS NOT NULL']
);
}
return !(
(this.props.datasource.type === 'druid' &&
TABLE_ONLY_OPERATORS.indexOf(operator) >= 0) ||
(this.props.datasource.type === 'table' &&
DRUID_ONLY_OPERATORS.indexOf(operator) >= 0) ||
(this.props.adhocFilter.clause === CLAUSES.HAVING &&
HAVING_OPERATORS.indexOf(operator) === -1)
);
}
focusComparator(ref, shouldFocus) {
if (ref && shouldFocus) {
ref.focus();
}
}
optionsRemaining() {
const { suggestions } = this.state;
const { comparator } = this.props.adhocFilter;
// if select is multi/value is array, we show the options not selected
const valuesFromSuggestionsLength = Array.isArray(comparator)
? comparator.filter(v => suggestions.includes(v)).length
: 0;
return suggestions?.length - valuesFromSuggestionsLength ?? 0;
}
createSuggestionsPlaceholder() {
const optionsRemaining = this.optionsRemaining();
const placeholder = t('%s option(s)', optionsRemaining);
return optionsRemaining ? placeholder : '';
}
renderSubjectOptionLabel(option) {
return <FilterDefinitionOption option={option} />;
}
clearSuggestionSearch() {
this.setState({ currentSuggestionSearch: '' });
}
render() {
const { adhocFilter, options, datasource } = this.props;
const { currentSuggestionSearch } = this.state;
let columns = options;
const { subject, operator, comparator } = adhocFilter;
const subjectSelectProps = {
value: subject ?? undefined,
onChange: this.onSubjectChange,
notFoundContent: t(
'No such column found. To filter on a metric, try the Custom SQL tab.',
),
filterOption: (input, option) =>
option.filterBy.toLowerCase().indexOf(input.toLowerCase()) >= 0,
autoFocus: !subject,
};
if (datasource.type === 'druid') {
subjectSelectProps.placeholder = t(
'%s column(s) and metric(s)',
columns.length,
);
} else {
// we cannot support simple ad-hoc filters for metrics because we don't know what type
// the value should be cast to (without knowing the output type of the aggregate, which
// becomes a rather complicated problem)
subjectSelectProps.placeholder =
adhocFilter.clause === CLAUSES.WHERE
? t('%s column(s)', columns.length)
: t('To filter on a metric, use Custom SQL tab.');
columns = options.filter(option => option.column_name);
}
const operatorSelectProps = {
placeholder: t(
'%s operator(s)',
OPERATORS_OPTIONS.filter(op => this.isOperatorRelevant(op, subject))
.length,
),
// like AGGREGATES_OPTIONS, operator options are string
value: operator,
onChange: this.onOperatorChange,
filterOption: (input, option) =>
option.value.toLowerCase().indexOf(input.toLowerCase()) >= 0,
autoFocus: !!subjectSelectProps.value && !operator,
};
const focusComparator =
!!subjectSelectProps.value && !!operatorSelectProps.value;
const comparatorSelectProps = {
allowClear: true,
showSearch: true,
mode: MULTI_OPERATORS.has(operator) && 'tags',
tokenSeparators: [',', '\n', '\t', ';'],
loading: this.state.loading,
value: comparator,
onChange: this.onComparatorChange,
notFoundContent: t('Type a value here'),
disabled: DISABLE_INPUT_OPERATORS.includes(operator),
placeholder: this.createSuggestionsPlaceholder(),
labelText: comparator?.length > 0 && this.createSuggestionsPlaceholder(),
autoFocus: focusComparator,
};
const Icon =
operator === 'NOT IN' ? Icons.StopOutlined : Icons.CheckOutlined;
return (
<>
<Select
css={theme => ({
marginTop: theme.gridUnit * 4,
marginBottom: theme.gridUnit * 4,
})}
{...this.selectProps}
{...subjectSelectProps}
name="filter-column"
getPopupContainer={triggerNode => triggerNode.parentNode}
>
{columns.map(column => (
<Select.Option
value={column.id || column.optionName}
filterBy={
column.saved_metric_name || column.column_name || column.label
}
key={column.id || column.optionName}
>
{this.renderSubjectOptionLabel(column)}
</Select.Option>
))}
</Select>
<Select
css={theme => ({ marginBottom: theme.gridUnit * 4 })}
{...this.selectProps}
{...operatorSelectProps}
getPopupContainer={triggerNode => triggerNode.parentNode}
name="filter-operator"
>
{OPERATORS_OPTIONS.filter(op =>
this.isOperatorRelevant(op, subject),
).map(option => (
<Select.Option value={option} key={option}>
{translateOperator(option)}
</Select.Option>
))}
</Select>
{MULTI_OPERATORS.has(operator) || this.state.suggestions.length > 0 ? (
<SelectWithLabel
data-test="adhoc-filter-simple-value"
name="filter-value"
{...comparatorSelectProps}
getPopupContainer={triggerNode => triggerNode.parentNode}
onSearch={val => this.setState({ currentSuggestionSearch: val })}
onSelect={this.clearSuggestionSearch}
onBlur={this.clearSuggestionSearch}
menuItemSelectedIcon={<Icon iconSize="m" />}
>
{this.state.suggestions.map(suggestion => (
<Select.Option value={suggestion} key={suggestion}>
{suggestion}
</Select.Option>
))}
{/* enable selecting an option not included in suggestions */}
{currentSuggestionSearch &&
!this.state.suggestions.some(
suggestion => suggestion === currentSuggestionSearch,
) && (
<Select.Option value={currentSuggestionSearch}>
{`${t('Create "%s"', currentSuggestionSearch)}`}
</Select.Option>
)}
</SelectWithLabel>
) : (
<Input
data-test="adhoc-filter-simple-value"
name="filter-value"
ref={ref => this.focusComparator(ref, focusComparator)}
onChange={this.onInputComparatorChange}
value={comparator}
placeholder={t('Filter value (case sensitive)')}
disabled={DISABLE_INPUT_OPERATORS.includes(operator)}
/>
)}
</>
);
}
}
AdhocFilterEditPopoverSimpleTabContent.propTypes = propTypes;
AdhocFilterEditPopoverSimpleTabContent.defaultProps = defaultProps;