diff --git a/static/app/components/searchQueryBuilder/tokens/filter/valueCombobox.tsx b/static/app/components/searchQueryBuilder/tokens/filter/valueCombobox.tsx index e449c18adb2551..a9a51ff33a5b00 100644 --- a/static/app/components/searchQueryBuilder/tokens/filter/valueCombobox.tsx +++ b/static/app/components/searchQueryBuilder/tokens/filter/valueCombobox.tsx @@ -103,7 +103,10 @@ function getMultiSelectInputValue(token: TokenResult) { return items.join(',') + ','; } -function prepareInputValueForSaving(valueType: FieldValueType, inputValue: string) { +export function prepareInputValueForSaving( + valueType: FieldValueType, + inputValue: string +) { const parsed = parseMultiSelectFilterValue(inputValue); if (!parsed) { @@ -126,7 +129,7 @@ function prepareInputValueForSaving(valueType: FieldValueType, inputValue: strin : (uniqueValues[0] ?? '""'); } -function getSelectedValuesFromText( +export function getSelectedValuesFromText( text: string, {escaped = true}: {escaped?: boolean} = {} ) { @@ -525,7 +528,7 @@ function ItemCheckbox({ ); } -function getInitialInputValue( +export function getInitialInputValue( token: TokenResult, canSelectMultipleValues: boolean ) { diff --git a/static/app/views/dashboards/globalFilter/filterSelector.tsx b/static/app/views/dashboards/globalFilter/filterSelector.tsx index ac0800f0e770e2..27316b9d2601e2 100644 --- a/static/app/views/dashboards/globalFilter/filterSelector.tsx +++ b/static/app/views/dashboards/globalFilter/filterSelector.tsx @@ -6,24 +6,42 @@ import {CompactSelect, type SelectOption} from '@sentry/scraps/compactSelect'; import {Flex} from '@sentry/scraps/layout'; import {Button} from 'sentry/components/core/button'; +import {DropdownMenu} from 'sentry/components/dropdownMenu'; import {HybridFilter} from 'sentry/components/organizations/hybridFilter'; import { + modifyFilterOperatorQuery, + modifyFilterValue, +} from 'sentry/components/searchQueryBuilder/hooks/useQueryBuilderState'; +import {getOperatorInfo} from 'sentry/components/searchQueryBuilder/tokens/filter/filterOperator'; +import { + escapeTagValue, + getFilterValueType, + OP_LABELS, +} from 'sentry/components/searchQueryBuilder/tokens/filter/utils'; +import { + getInitialInputValue, getPredefinedValues, + getSelectedValuesFromText, + prepareInputValueForSaving, tokenSupportsMultipleValues, } from 'sentry/components/searchQueryBuilder/tokens/filter/valueCombobox'; -import {MutableSearch} from 'sentry/components/searchSyntax/mutableSearch'; +import {TermOperator} from 'sentry/components/searchSyntax/parser'; +import {IconChevron} from 'sentry/icons'; import {t} from 'sentry/locale'; -import {space} from 'sentry/styles/space'; +import {prettifyTagKey} from 'sentry/utils/fields'; import {keepPreviousData, useQuery} from 'sentry/utils/queryClient'; import {middleEllipsis} from 'sentry/utils/string/middleEllipsis'; import {useDebouncedValue} from 'sentry/utils/useDebouncedValue'; import usePageFilters from 'sentry/utils/usePageFilters'; 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 FilterSelectorTrigger, { + FilterValueTruncated, +} from 'sentry/views/dashboards/globalFilter/filterSelectorTrigger'; import { getFieldDefinitionForDataset, getFilterToken, + parseFilterValue, } from 'sentry/views/dashboards/globalFilter/utils'; import type {GlobalFilter} from 'sentry/views/dashboards/types'; @@ -40,12 +58,58 @@ function FilterSelector({ onRemoveFilter, onUpdateFilter, }: FilterSelectorProps) { - // Parse global filter condition to retrieve initial state - const initialValues = useMemo(() => { - const mutableSearch = new MutableSearch(globalFilter.value); - return mutableSearch.getFilterValues(globalFilter.tag.key); + const {selection} = usePageFilters(); + + const {fieldDefinition, filterToken} = useMemo(() => { + const fieldDef = getFieldDefinitionForDataset(globalFilter.tag, globalFilter.dataset); + return { + fieldDefinition: fieldDef, + filterToken: getFilterToken(globalFilter, fieldDef), + }; }, [globalFilter]); + // Get initial selected values from the filter token + const initialValues = useMemo(() => { + if (!filterToken) { + return []; + } + const initialValue = globalFilter.value + ? getInitialInputValue(filterToken, true) + : ''; + const selectedValues = getSelectedValuesFromText(initialValue, {escaped: false}); + return selectedValues.map(item => item.value); + }, [filterToken, globalFilter.value]); + + // Get operator info from the filter token + const {initialOperator, operatorDropdownItems} = useMemo(() => { + if (!filterToken) { + return { + initialOperator: TermOperator.DEFAULT, + operatorDropdownItems: [], + }; + } + + const operatorInfo = getOperatorInfo({ + filterToken, + hasWildcardOperators: true, + fieldDefinition, + }); + + return { + initialOperator: operatorInfo?.operator ?? TermOperator.DEFAULT, + operatorDropdownItems: (operatorInfo?.options ?? []).map(option => ({ + ...option, + key: option.value, + label: option.label, + textValue: option.textValue, + onClick: () => { + setStagedOperator(option.value); + }, + })), + }; + }, [filterToken, fieldDefinition]); + + const [stagedOperator, setStagedOperator] = useState(initialOperator); const [activeFilterValues, setActiveFilterValues] = useState(initialValues); const [stagedFilterValues, setStagedFilterValues] = useState([]); const [searchQuery, setSearchQuery] = useState(''); @@ -55,18 +119,9 @@ function FilterSelector({ setStagedFilterValues([]); }, [initialValues]); - const {dataset, tag} = globalFilter; - const {selection} = usePageFilters(); - // Retrieve full tag definition to check if it has predefined values const datasetFilterKeys = searchBarData.getFilterKeys(); - const fullTag = datasetFilterKeys[tag.key]; - const fieldDefinition = getFieldDefinitionForDataset(tag, dataset); - - const filterToken = useMemo( - () => getFilterToken(globalFilter, fieldDefinition), - [globalFilter, fieldDefinition] - ); + const fullTag = datasetFilterKeys[globalFilter.tag.key]; const canSelectMultipleValues = filterToken ? tokenSupportsMultipleValues(filterToken, datasetFilterKeys, fieldDefinition) @@ -92,8 +147,13 @@ function FilterSelector({ : true; const baseQueryKey = useMemo( - () => ['global-dashboard-filters-tag-values', tag, selection, searchQuery], - [tag, selection, searchQuery] + () => [ + 'global-dashboard-filters-tag-values', + globalFilter.tag.key, + selection, + searchQuery, + ], + [globalFilter.tag.key, selection, searchQuery] ); const queryKey = useDebouncedValue(baseQueryKey); @@ -102,7 +162,7 @@ function FilterSelector({ // eslint-disable-next-line @tanstack/query/exhaustive-deps queryKey, queryFn: async () => { - const result = await searchBarData.getTagValues(tag, searchQuery); + const result = await searchBarData.getTagValues(globalFilter.tag, searchQuery); return result ?? []; }, placeholderData: keepPreviousData, @@ -163,26 +223,46 @@ function FilterSelector({ ]); const handleChange = (opts: string[]) => { - if (isEqual(opts, activeFilterValues)) { + if (isEqual(opts, activeFilterValues) && stagedOperator === initialOperator) { return; } + if (!filterToken) { + return; + } + setActiveFilterValues(opts); + if (opts.length === 0) { + setStagedOperator(TermOperator.DEFAULT); + onUpdateFilter({ + ...globalFilter, + value: '', + }); + return; + } - // Build filter condition string - const mutableSearch = new MutableSearch(''); + let newValue = ''; + if (opts.length !== 0) { + const cleanedValue = prepareInputValueForSaving( + getFilterValueType(filterToken, fieldDefinition), + opts.map(opt => escapeTagValue(opt, {allowArrayValue: false})).join(',') + ); + newValue = modifyFilterValue(filterToken.text, filterToken, cleanedValue); + } - let filterValue = ''; - if (opts.length === 1) { - filterValue = mutableSearch.addFilterValue(tag.key, opts[0]!).toString(); - } else if (opts.length > 1) { - filterValue = mutableSearch.addFilterValueList(tag.key, opts).toString(); + if (stagedOperator !== initialOperator) { + const newToken = parseFilterValue(newValue, globalFilter)[0] ?? filterToken; + newValue = modifyFilterOperatorQuery(newToken.text, newToken, stagedOperator, true); } + onUpdateFilter({ ...globalFilter, - value: filterValue, + value: newValue, }); }; + const hasOperatorChanges = + stagedFilterValues.length > 0 && stagedOperator !== initialOperator; + const renderMenuHeaderTrailingItems = ({closeOverlay}: any) => ( {activeFilterValues.length > 0 && ( @@ -212,7 +292,8 @@ function FilterSelector({ const renderFilterSelectorTrigger = () => ( @@ -233,7 +314,9 @@ function FilterSelector({ setStagedFilterValues([]); }} menuTitle={ - {t('%s Filter', getDatasetLabel(dataset))} + + {t('%s Filter', getDatasetLabel(globalFilter.dataset))} + } menuHeaderTrailingItems={renderMenuHeaderTrailingItems} triggerProps={{ @@ -250,9 +333,10 @@ function FilterSelector({ disabled={false} options={options} value={activeFilterValues} - searchPlaceholder={t('Search filter values...')} + searchPlaceholder={t('Search or enter a custom value...')} onSearch={setSearchQuery} defaultValue={[]} + hasExternalChanges={hasOperatorChanges} onChange={handleChange} onStagedValueChange={value => { setStagedFilterValues(value); @@ -261,13 +345,34 @@ function FilterSelector({ onClose={() => { setSearchQuery(''); setStagedFilterValues([]); + setStagedOperator(initialOperator); }} sizeLimitMessage={t('Use search to find more filter values…')} emptyMessage={ isFetching ? t('Loading filter values...') : t('No filter values found') } menuTitle={ - {t('%s Filter', getDatasetLabel(dataset))} + + + ( + + + {prettifyTagKey(globalFilter.tag.key)} + + + + )} + items={operatorDropdownItems} + /> + + } menuHeaderTrailingItems={renderMenuHeaderTrailingItems} triggerProps={{ @@ -283,7 +388,7 @@ const StyledButton = styled(Button)` font-size: inherit; font-weight: ${p => p.theme.fontWeight.normal}; color: ${p => p.theme.subText}; - padding: 0 ${space(0.5)}; + padding: 0 ${p => p.theme.space.xs}; margin: ${p => p.theme.isChonk ? `-${p.theme.space.xs} -${p.theme.space.xs}` @@ -295,3 +400,16 @@ export const MenuTitleWrapper = styled('span')` padding-top: ${p => p.theme.space.xs}; padding-bottom: ${p => p.theme.space.xs}; `; + +const OperatorFlex = styled(Flex)` + margin-left: -${p => p.theme.space.sm}; +`; + +const WildcardButton = styled(Flex)` + padding: 0 ${p => p.theme.space.md}; +`; + +const SubText = styled('span')` + color: ${p => p.theme.subText}; + font-size: ${p => p.theme.fontSize.sm}; +`; diff --git a/static/app/views/dashboards/globalFilter/filterSelectorTrigger.tsx b/static/app/views/dashboards/globalFilter/filterSelectorTrigger.tsx index f24945da46190d..4fab9ffd197049 100644 --- a/static/app/views/dashboards/globalFilter/filterSelectorTrigger.tsx +++ b/static/app/views/dashboards/globalFilter/filterSelectorTrigger.tsx @@ -5,8 +5,9 @@ import {Flex} from '@sentry/scraps/layout'; import {Badge} from 'sentry/components/core/badge'; import type {SelectOption} from 'sentry/components/core/compactSelect/types'; import LoadingIndicator from 'sentry/components/loadingIndicator'; +import {OP_LABELS} from 'sentry/components/searchQueryBuilder/tokens/filter/utils'; +import {TermOperator} from 'sentry/components/searchSyntax/parser'; import {t} from 'sentry/locale'; -import {space} from 'sentry/styles/space'; import {prettifyTagKey} from 'sentry/utils/fields'; import type {UseQueryResult} from 'sentry/utils/queryClient'; import type {GlobalFilter} from 'sentry/views/dashboards/types'; @@ -14,6 +15,7 @@ import type {GlobalFilter} from 'sentry/views/dashboards/types'; type FilterSelectorTriggerProps = { activeFilterValues: string[]; globalFilter: GlobalFilter; + operator: TermOperator; options: Array>; queryResult: UseQueryResult; }; @@ -21,6 +23,7 @@ type FilterSelectorTriggerProps = { function FilterSelectorTrigger({ globalFilter, activeFilterValues, + operator, options, queryResult, }: FilterSelectorTriggerProps) { @@ -36,12 +39,15 @@ function FilterSelectorTrigger({ const tagKey = prettifyTagKey(tag.key); const filterValue = activeFilterValues[0] ?? ''; - const separator = {':'}; + const isDefaultOperator = operator === TermOperator.DEFAULT; + const opLabel = isDefaultOperator ? ':' : OP_LABELS[operator]; return ( - - {tagKey} - {separator} + + + {tagKey} + {opLabel} + {!isFetching && ( {isAllSelected ? ( @@ -64,7 +70,7 @@ export default FilterSelectorTrigger; const StyledLoadingIndicator = styled(LoadingIndicator)` && { margin: 0; - margin-left: ${space(0.5)}; + margin-left: ${p => p.theme.space.xs}; } `; @@ -75,19 +81,20 @@ const StyledBadge = styled(Badge)` min-width: 16px; border-radius: 16px; font-size: 10px; - padding: 0 ${space(0.5)}; + padding: 0 ${p => p.theme.space.xs}; `; const ButtonLabelWrapper = styled(Flex)` align-items: center; `; -const FilterValueSeparator = styled('span')` - margin-right: ${space(0.5)}; -`; - -const FilterValueTruncated = styled('div')` +export const FilterValueTruncated = styled('div')` ${p => p.theme.overflowEllipsis}; max-width: 300px; width: min-content; `; + +const SubText = styled('span')` + color: ${p => p.theme.gray400}; + font-weight: ${p => p.theme.fontWeight.normal}; +`;