Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 160 additions & 35 deletions static/app/views/dashboards/globalFilter/filterSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from '@sentry/scraps/compactSelect';
import {Flex} from '@sentry/scraps/layout';
import {OverlayTrigger} from '@sentry/scraps/overlayTrigger';
import {Text} from '@sentry/scraps/text';

import {DropdownMenu} from 'sentry/components/dropdownMenu';
import {LoadingIndicator} from 'sentry/components/loadingIndicator';
Expand Down Expand Up @@ -46,8 +47,14 @@ import {type SearchBarData} from 'sentry/views/dashboards/datasetConfig/base';
import {getDatasetLabel} from 'sentry/views/dashboards/globalFilter/addFilter';
import {FilterSelectorTrigger} from 'sentry/views/dashboards/globalFilter/filterSelectorTrigger';
import {
buildNoValueFilterQuery,
getFieldDefinitionForDataset,
getFilterToken,
getNoValueOperator,
getValueFilterToken,
hasNoValueFilter,
NO_VALUE_SENTINEL,
NO_VALUE_SUPPORTED_OPERATORS,
parseFilterValue,
} from 'sentry/views/dashboards/globalFilter/utils';
import {WidgetType, type GlobalFilter} from 'sentry/views/dashboards/types';
Expand Down Expand Up @@ -77,12 +84,27 @@ export function FilterSelector({
// Ref to break the circular dependency: options need toggleOption, but toggleOption
// comes from useStagedCompactSelect which depends on options.
const toggleOptionRef = useRef<((val: string) => void) | undefined>(undefined);
const stagedValueRef = useRef<string[]>([]);

const {fieldDefinition, filterToken} = useMemo(() => {
const fieldDef = getFieldDefinitionForDataset(globalFilter.tag, globalFilter.dataset);

// For "(no value)" filters, drive the UI from the value token when present,
// otherwise a default token (the bare has:/!has: has no value to edit).
const allTokens = globalFilter.value
? parseFilterValue(globalFilter.value, globalFilter)
: [];
const containsNoValue = hasNoValueFilter(allTokens);
const valueToken = containsNoValue ? getValueFilterToken(allTokens) : null;

return {
fieldDefinition: fieldDef,
filterToken: getFilterToken(globalFilter, fieldDef),
filterToken:
valueToken ??
getFilterToken(
containsNoValue ? {...globalFilter, value: ''} : globalFilter,
fieldDef
),
};
}, [globalFilter]);

Expand All @@ -91,12 +113,30 @@ export function FilterSelector({
if (!filterToken) {
return [];
}
const initialValue = globalFilter.value
? getInitialInputValue(filterToken, true)
: '';

const allTokens = globalFilter.value
? parseFilterValue(globalFilter.value, globalFilter)
: [];
const includesNoValue = hasNoValueFilter(allTokens);
const valueToken = includesNoValue ? getValueFilterToken(allTokens) : null;
const tokenForParsing = valueToken ?? filterToken;

const initialValue =
globalFilter.value && !includesNoValue
? getInitialInputValue(tokenForParsing, true)
: valueToken
? getInitialInputValue(valueToken, true)
: '';

const selectedValues = getSelectedValuesFromText(initialValue);
return selectedValues.map(item => item.value);
}, [filterToken, globalFilter.value]);
const values = selectedValues.map(item => item.value);

if (includesNoValue) {
values.push(NO_VALUE_SENTINEL);
}

return values;
}, [filterToken, globalFilter]);

// Get operator info from the filter token
const {initialOperator, operatorDropdownItems} = useMemo(() => {
Expand All @@ -109,19 +149,36 @@ export function FilterSelector({

const operatorInfo = getOperatorInfo({filterToken, fieldDefinition});

// A bare has:/!has: has no value token, so derive its operator from the
// HAS polarity instead of the synthetic (always DEFAULT) filterToken.
const allTokens = globalFilter.value
? parseFilterValue(globalFilter.value, globalFilter)
: [];
const noValueOperator =
hasNoValueFilter(allTokens) && !getValueFilterToken(allTokens)
? getNoValueOperator(allTokens)
: null;

return {
initialOperator: operatorInfo?.operator ?? TermOperator.DEFAULT,
initialOperator: noValueOperator ?? operatorInfo?.operator ?? TermOperator.DEFAULT,
operatorDropdownItems: (operatorInfo?.options ?? []).map(option => ({
...option,
key: option.value,
label: option.label,
textValue: option.textValue,
onClick: () => {
setStagedOperator(option.value);
// Deselect "(no value)" when switching to an unsupported operator.
if (
!NO_VALUE_SUPPORTED_OPERATORS.has(option.value) &&
stagedValueRef.current.includes(NO_VALUE_SENTINEL)
) {
toggleOptionRef.current?.(NO_VALUE_SENTINEL);
}
},
})),
};
}, [filterToken, fieldDefinition]);
}, [filterToken, fieldDefinition, globalFilter]);

const [stagedOperator, setStagedOperator] = useState(initialOperator);
const [activeFilterValues, setActiveFilterValues] = useState(initialValues);
Expand Down Expand Up @@ -200,13 +257,42 @@ export function FilterSelector({
const {data: fetchedFilterValues, isFetching} = queryResult;

const options = useMemo((): Array<SelectOption<string>> => {
// "(no value)" option, prepended for supported operators.
const buildNoValueOption = (): SelectOption<string> | null => {
if (!NO_VALUE_SUPPORTED_OPERATORS.has(stagedOperator)) {
return null;
}
const noValueOption: SelectOption<string> = {
label: <Text variant="muted">{t('(no value)')}</Text>,
textValue: t('(no value)'),
value: NO_VALUE_SENTINEL,
};
if (canSelectMultipleValues) {
noValueOption.leadingItems = ({isSelected}: {isSelected: boolean}) => (
<Checkbox
checked={isSelected}
onChange={() => toggleOptionRef.current?.(NO_VALUE_SENTINEL)}
aria-label={t('Select %s', t('(no value)'))}
tabIndex={-1}
/>
);
}
return noValueOption;
};

if (predefinedValues && !canSelectMultipleValues) {
return predefinedValues.flatMap(section =>
section.suggestions.map(suggestion => ({
label: suggestion.value,
value: suggestion.value,
}))
const predefinedOptions: Array<SelectOption<string>> = predefinedValues.flatMap(
section =>
section.suggestions.map(suggestion => ({
label: suggestion.value,
value: suggestion.value,
}))
);
const noValueOption = buildNoValueOption();
if (noValueOption) {
predefinedOptions.unshift(noValueOption);
}
return predefinedOptions;
}

const optionMap = new Map<string, SelectOption<string>>();
Expand Down Expand Up @@ -243,8 +329,10 @@ export function FilterSelector({
return map.set(value, option);
};

// Filter values in the global filter
activeFilterValues.forEach(value => addOption(value, optionMap));
// Filter values in the global filter (sentinel is added separately)
activeFilterValues
.filter(value => value !== NO_VALUE_SENTINEL)
.forEach(value => addOption(value, optionMap));

// Predefined values
predefinedValues?.forEach(suggestionSection => {
Expand All @@ -263,11 +351,21 @@ export function FilterSelector({
}
// Staged filter values inside the filter selector
stagedFilterValues.forEach(value => {
if (!optionMap.has(value)) {
if (value !== NO_VALUE_SENTINEL && !optionMap.has(value)) {
addOption(value, fixedOptionMap);
}
});
return [...Array.from(fixedOptionMap.values()), ...Array.from(optionMap.values())];
const allOptions = [
...Array.from(fixedOptionMap.values()),
...Array.from(optionMap.values()),
];

const noValueOption = buildNoValueOption();
if (noValueOption) {
allOptions.unshift(noValueOption);
}

return allOptions;
}, [
fetchedFilterValues,
predefinedValues,
Expand All @@ -276,11 +374,17 @@ export function FilterSelector({
searchQuery,
canSelectMultipleValues,
globalFilter.tag.key,
stagedOperator,
]);

const translatedOptions = translateKnownFilterOptions(options, globalFilter);

const handleChange = (opts: string[]) => {
const handleChange = (rawOpts: string[]) => {
// Strip the sentinel if the current operator doesn't support it
const opts = NO_VALUE_SUPPORTED_OPERATORS.has(stagedOperator)
? rawOpts
: rawOpts.filter(opt => opt !== NO_VALUE_SENTINEL);

if (isEqual(opts, activeFilterValues) && stagedOperator === initialOperator) {
return;
}
Expand All @@ -298,20 +402,33 @@ export function FilterSelector({
return;
}

let newValue = '';
if (opts.length !== 0) {
const includeNoValue = opts.includes(NO_VALUE_SENTINEL);
const valueOpts = opts.filter(opt => opt !== NO_VALUE_SENTINEL);

let valueQuery = '';
if (valueOpts.length > 0) {
const cleanedValue = prepareInputValueForSaving(
getFilterValueType(filterToken, fieldDefinition),
opts.map(opt => escapeTagValueForSearch(opt, {allowArrayValue: false})).join(',')
valueOpts
.map(opt => escapeTagValueForSearch(opt, {allowArrayValue: false}))
.join(',')
);
newValue = modifyFilterValue(filterToken.text, filterToken, cleanedValue);
}
valueQuery = modifyFilterValue(filterToken.text, filterToken, cleanedValue);

if (stagedOperator !== initialOperator) {
const newToken = parseFilterValue(newValue, globalFilter)[0] ?? filterToken;
newValue = modifyFilterOperatorQuery(newToken.text, newToken, stagedOperator);
if (stagedOperator !== initialOperator) {
const newToken = parseFilterValue(valueQuery, globalFilter)[0] ?? filterToken;
valueQuery = modifyFilterOperatorQuery(newToken.text, newToken, stagedOperator);
}
}

const newValue = includeNoValue
? buildNoValueFilterQuery(
globalFilter.tag.key,
stagedOperator,
valueQuery || undefined
)
: valueQuery;

onUpdateFilter({
...globalFilter,
value: newValue,
Expand All @@ -330,23 +447,31 @@ export function FilterSelector({
hasExternalChanges: hasOperatorChanges,
});

// Wire up toggleOptionRef after stagedSelect is created to break the circular
// Wire up refs after stagedSelect is created to break the circular
// dependency between options (which need toggleOption) and useStagedCompactSelect
// (which needs options).
toggleOptionRef.current = stagedSelect.toggleOption;
stagedValueRef.current = stagedSelect.value;

const {dispatch} = stagedSelect;
const hasStagedChanges =
xor(stagedSelect.value, activeFilterValues).length > 0 || hasOperatorChanges;

const renderFilterSelectorTrigger = (filterValues: string[]) => (
<FilterSelectorTrigger
globalFilter={globalFilter}
activeFilterValues={filterValues}
operator={stagedOperator}
options={translatedOptions}
/>
);
const renderFilterSelectorTrigger = (filterValues: string[]) => {
// Strip the sentinel from display when the operator doesn't support it
const displayValues = NO_VALUE_SUPPORTED_OPERATORS.has(stagedOperator)
? filterValues
: filterValues.filter(v => v !== NO_VALUE_SENTINEL);

return (
<FilterSelectorTrigger
globalFilter={globalFilter}
activeFilterValues={displayValues}
operator={stagedOperator}
options={translatedOptions}
/>
);
};

const loadingFooter = isFetching ? (
<Flex justify="center" padding="xs">
Expand Down
56 changes: 56 additions & 0 deletions static/app/views/dashboards/globalFilter/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {cleanFilterValue} from 'sentry/components/searchQueryBuilder/tokens/filt
import {getInitialFilterText} from 'sentry/components/searchQueryBuilder/tokens/utils';
import {parseQueryBuilderValue} from 'sentry/components/searchQueryBuilder/utils';
import {
FilterType,
TermOperator,
Token,
type TokenResult,
Expand Down Expand Up @@ -159,3 +160,58 @@ export function newNumericFilterQuery(
);
return newFilterQuery;
}

/** Sentinel for the "(no value)" option; never sent to the backend. */
export const NO_VALUE_SENTINEL = '__no_value__';

export const NO_VALUE_SUPPORTED_OPERATORS = new Set<TermOperator>([
TermOperator.DEFAULT,
TermOperator.NOT_EQUAL,
TermOperator.CONTAINS,
TermOperator.DOES_NOT_CONTAIN,
]);

/** Negated operators flip "(no value)" from "tag absent" to "tag present". */
export function isNegatedNoValueOperator(operator: TermOperator): boolean {
return (
operator === TermOperator.NOT_EQUAL || operator === TermOperator.DOES_NOT_CONTAIN
);
}

export function hasNoValueFilter(
filterTokens: Array<TokenResult<Token.FILTER>>
): boolean {
return filterTokens.some(token => token.filter === FilterType.HAS);
}

/** Maps a HAS token's polarity to an operator: `!has:` → `is`, `has:` → `is not`. */
export function getNoValueOperator(
filterTokens: Array<TokenResult<Token.FILTER>>
): TermOperator | null {
const hasToken = filterTokens.find(token => token.filter === FilterType.HAS);
if (!hasToken) {
return null;
}
return hasToken.negated ? TermOperator.DEFAULT : TermOperator.NOT_EQUAL;
}

/** First non-HAS token, e.g. `browser:firefox` in `(browser:firefox OR !has:browser)`. */
export function getValueFilterToken(
filterTokens: Array<TokenResult<Token.FILTER>>
): TokenResult<Token.FILTER> | null {
return filterTokens.find(token => token.filter !== FilterType.HAS) ?? null;
}

export function buildNoValueFilterQuery(
tagKey: string,
operator: TermOperator,
valueQuery?: string
): string {
const noValuePart = isNegatedNoValueOperator(operator)
? `has:${tagKey}`
: `!has:${tagKey}`;
if (!valueQuery) {
return noValuePart;
}
return `(${valueQuery} OR ${noValuePart})`;
}
Loading