diff --git a/static/app/views/dashboards/globalFilter/filterSelector.tsx b/static/app/views/dashboards/globalFilter/filterSelector.tsx index 05d145a8125c..2b6bdfc13e97 100644 --- a/static/app/views/dashboards/globalFilter/filterSelector.tsx +++ b/static/app/views/dashboards/globalFilter/filterSelector.tsx @@ -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'; @@ -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'; @@ -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([]); 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]); @@ -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(() => { @@ -109,8 +149,18 @@ 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, @@ -118,10 +168,17 @@ export function FilterSelector({ 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); @@ -200,13 +257,42 @@ export function FilterSelector({ const {data: fetchedFilterValues, isFetching} = queryResult; const options = useMemo((): Array> => { + // "(no value)" option, prepended for supported operators. + const buildNoValueOption = (): SelectOption | null => { + if (!NO_VALUE_SUPPORTED_OPERATORS.has(stagedOperator)) { + return null; + } + const noValueOption: SelectOption = { + label: {t('(no value)')}, + textValue: t('(no value)'), + value: NO_VALUE_SENTINEL, + }; + if (canSelectMultipleValues) { + noValueOption.leadingItems = ({isSelected}: {isSelected: boolean}) => ( + 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> = 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>(); @@ -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 => { @@ -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, @@ -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; } @@ -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, @@ -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[]) => ( - - ); + 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 ( + + ); + }; const loadingFooter = isFetching ? ( diff --git a/static/app/views/dashboards/globalFilter/utils.tsx b/static/app/views/dashboards/globalFilter/utils.tsx index 736c2255c3ed..9d2fc56b935e 100644 --- a/static/app/views/dashboards/globalFilter/utils.tsx +++ b/static/app/views/dashboards/globalFilter/utils.tsx @@ -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, @@ -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.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> +): 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> +): 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 | 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})`; +}