From 2b0ff5ae4832a5ae59f2f12581a636ff655e81cd Mon Sep 17 00:00:00 2001 From: tomerqodo Date: Thu, 4 Dec 2025 22:44:01 +0200 Subject: [PATCH] Apply changes for benchmark PR --- .../components/searchQueryBuilder/context.tsx | 3 + .../hooks/useQueryBuilderState.tsx | 14 ++-- .../searchQueryBuilder/index.spec.tsx | 63 ++++++++++++++-- .../searchQueryBuilder/tokens/boolean.tsx | 10 +-- .../tokens/filterKeyListBox/index.tsx | 26 ++++--- .../tokens/filterKeyListBox/types.tsx | 11 ++- .../filterKeyListBox/useFilterKeyListBox.tsx | 75 +++++++++++++++++-- .../tokens/filterKeyListBox/utils.tsx | 19 +++++ .../searchQueryBuilder/tokens/freeText.tsx | 13 ++++ 9 files changed, 195 insertions(+), 39 deletions(-) diff --git a/static/app/components/searchQueryBuilder/context.tsx b/static/app/components/searchQueryBuilder/context.tsx index 2ed71b836c224d..3e8ab5c6ffe458 100644 --- a/static/app/components/searchQueryBuilder/context.tsx +++ b/static/app/components/searchQueryBuilder/context.tsx @@ -40,6 +40,7 @@ interface SearchQueryBuilderContextData { currentInputValueRef: React.RefObject; disabled: boolean; disallowFreeText: boolean; + disallowLogicalOperators: boolean; disallowWildcard: boolean; dispatch: Dispatch; displayAskSeer: boolean; @@ -204,6 +205,7 @@ export function SearchQueryBuilderProvider({ ...state, disabled, disallowFreeText: Boolean(disallowFreeText), + disallowLogicalOperators: Boolean(disallowLogicalOperators), disallowWildcard: Boolean(disallowWildcard), enableAISearch, parseQuery, @@ -245,6 +247,7 @@ export function SearchQueryBuilderProvider({ caseInsensitive, disabled, disallowFreeText, + disallowLogicalOperators, disallowWildcard, dispatch, displayAskSeer, diff --git a/static/app/components/searchQueryBuilder/hooks/useQueryBuilderState.tsx b/static/app/components/searchQueryBuilder/hooks/useQueryBuilderState.tsx index 103afeeeb69976..b2c923a7edbdae 100644 --- a/static/app/components/searchQueryBuilder/hooks/useQueryBuilderState.tsx +++ b/static/app/components/searchQueryBuilder/hooks/useQueryBuilderState.tsx @@ -203,9 +203,9 @@ type UpdateAggregateArgsAction = { focusOverride?: FocusOverride; }; -type UpdateBooleanOperatorAction = { +type UpdateLogicOperatorAction = { token: TokenResult; - type: 'UPDATE_BOOLEAN_OPERATOR'; + type: 'UPDATE_LOGIC_OPERATOR'; value: string; }; @@ -245,7 +245,7 @@ export type QueryBuilderActions = | UpdateAggregateArgsAction | MultiSelectFilterValueAction | ResetClearAskSeerFeedbackAction - | UpdateBooleanOperatorAction; + | UpdateLogicOperatorAction; function removeQueryTokensFromQuery( query: string, @@ -798,9 +798,9 @@ function updateFreeTextAndReplaceText( }; } -function updateBooleanOperator( +function updateLogicOperator( state: QueryBuilderState, - action: UpdateBooleanOperatorAction + action: UpdateLogicOperatorAction ): QueryBuilderState { const newQuery = replaceQueryToken(state.query, action.token, action.value); if (newQuery === state.query) { @@ -995,8 +995,8 @@ export function useQueryBuilderState({ ...state, query: modifyFilterValue(state.query, action.token, action.value), }; - case 'UPDATE_BOOLEAN_OPERATOR': - return updateBooleanOperator(state, action); + case 'UPDATE_LOGIC_OPERATOR': + return updateLogicOperator(state, action); case 'UPDATE_AGGREGATE_ARGS': return updateAggregateArgs(state, action, {getFieldDefinition}); case 'TOGGLE_FILTER_VALUE': diff --git a/static/app/components/searchQueryBuilder/index.spec.tsx b/static/app/components/searchQueryBuilder/index.spec.tsx index 03cc73f93e16e8..96952754d4b7f3 100644 --- a/static/app/components/searchQueryBuilder/index.spec.tsx +++ b/static/app/components/searchQueryBuilder/index.spec.tsx @@ -425,7 +425,9 @@ describe('SearchQueryBuilder', () => { describe('filter key menu', () => { it('breaks keys into sections', async () => { - render(); + render(, { + organization: {features: ['search-query-builder-conditionals-combobox-menus']}, + }); await userEvent.click(screen.getByRole('combobox', {name: 'Add a search term'})); // Should show tab button for each section @@ -713,6 +715,29 @@ describe('SearchQueryBuilder', () => { ); }); }); + + describe('logic category', () => { + it('does not render logic category when on first input', async () => { + render(, { + organization: {features: ['search-query-builder-conditionals-combobox-menus']}, + }); + + await userEvent.click(getLastInput()); + expect(await screen.findByRole('button', {name: 'All'})).toBeInTheDocument(); + + expect(screen.queryByRole('button', {name: 'Logic'})).not.toBeInTheDocument(); + }); + + it('renders logic category when not on first input', async () => { + render(, { + organization: {features: ['search-query-builder-conditionals-combobox-menus']}, + }); + + await userEvent.click(getLastInput()); + // Should show conditionals button + expect(await screen.findByRole('button', {name: 'Logic'})).toBeInTheDocument(); + }); + }); }); describe('mouse interactions', () => { @@ -810,9 +835,9 @@ describe('SearchQueryBuilder', () => { expect(screen.queryByRole('row', {name: '('})).not.toBeInTheDocument(); }); - describe('boolean ops', () => { + describe('logic ops', () => { describe('flag disabled', () => { - it('can remove boolean ops by clicking the delete button', async () => { + it('can remove logic ops by clicking the delete button', async () => { render(); expect(screen.getByRole('row', {name: 'OR'})).toBeInTheDocument(); @@ -823,7 +848,7 @@ describe('SearchQueryBuilder', () => { }); describe('flag enabled', () => { - it('can remove boolean selector by clicking the delete button', async () => { + it('can remove logic selector by clicking the delete button', async () => { render(, { organization: { features: ['search-query-builder-add-boolean-operator-select'], @@ -831,12 +856,14 @@ describe('SearchQueryBuilder', () => { }); expect(screen.getByRole('row', {name: 'OR'})).toBeInTheDocument(); - await userEvent.click(screen.getByRole('button', {name: 'Remove boolean: OR'})); + await userEvent.click( + screen.getByRole('button', {name: 'Remove logic operator: OR'}) + ); expect(screen.queryByRole('row', {name: 'OR'})).not.toBeInTheDocument(); }); - it('can select a different boolean operator', async () => { + it('can select a different logic operator', async () => { render(, { organization: { features: ['search-query-builder-add-boolean-operator-select'], @@ -845,7 +872,7 @@ describe('SearchQueryBuilder', () => { expect(screen.getByRole('row', {name: 'OR'})).toBeInTheDocument(); await userEvent.click( - screen.getByRole('button', {name: 'Edit boolean operator: OR'}) + screen.getByRole('button', {name: 'Edit logic operator: OR'}) ); await userEvent.click(screen.getByRole('option', {name: 'AND'})); @@ -1518,7 +1545,7 @@ describe('SearchQueryBuilder', () => { expect(screen.queryByRole('row', {name: '('})).not.toBeInTheDocument(); }); - it('can remove boolean ops with the keyboard', async () => { + it('can remove logic ops with the keyboard', async () => { render(); expect(screen.getByRole('row', {name: 'and'})).toBeInTheDocument(); @@ -3734,6 +3761,26 @@ describe('SearchQueryBuilder', () => { await screen.findByText('Parentheses are not supported in this search') ).toBeInTheDocument(); }); + + it('should not add the conditionals section to filter key menu', async () => { + render( + , + { + organization: {features: ['search-query-builder-conditionals-combobox-menus']}, + } + ); + + await userEvent.click(getLastInput()); + expect(await screen.findByRole('button', {name: 'All'})).toBeInTheDocument(); + + await waitFor(() => + expect(screen.queryByRole('button', {name: 'Logic'})).not.toBeInTheDocument() + ); + }); }); describe('disallowWildcard', () => { diff --git a/static/app/components/searchQueryBuilder/tokens/boolean.tsx b/static/app/components/searchQueryBuilder/tokens/boolean.tsx index fab5c16855579e..0670b880018365 100644 --- a/static/app/components/searchQueryBuilder/tokens/boolean.tsx +++ b/static/app/components/searchQueryBuilder/tokens/boolean.tsx @@ -75,7 +75,7 @@ function FilterDelete({token, state, item}: SearchQueryBuilderBooleanProps) { return ( { dispatch({type: 'DELETE_TOKEN', token}); }} @@ -88,7 +88,7 @@ function FilterDelete({token, state, item}: SearchQueryBuilderBooleanProps) { ); } -const BOOLEAN_OPERATOR_OPTIONS = [ +const LOGIC_OPERATOR_OPTIONS = [ {value: 'AND', label: 'AND'}, {value: 'OR', label: 'OR'}, ]; @@ -152,12 +152,12 @@ function SearchQueryBuilderBooleanSelect({ disabled={disabled} size="sm" value={tokenText} - options={BOOLEAN_OPERATOR_OPTIONS} + options={LOGIC_OPERATOR_OPTIONS} trigger={triggerProps => { return ( @@ -167,7 +167,7 @@ function SearchQueryBuilderBooleanSelect({ }} onOpenChange={setFilterMenuOpen} onChange={option => { - dispatch({type: 'UPDATE_BOOLEAN_OPERATOR', token, value: option.value}); + dispatch({type: 'UPDATE_LOGIC_OPERATOR', token, value: option.value}); }} /> diff --git a/static/app/components/searchQueryBuilder/tokens/filterKeyListBox/index.tsx b/static/app/components/searchQueryBuilder/tokens/filterKeyListBox/index.tsx index 7ea35fe605359d..d60dec5e25e578 100644 --- a/static/app/components/searchQueryBuilder/tokens/filterKeyListBox/index.tsx +++ b/static/app/components/searchQueryBuilder/tokens/filterKeyListBox/index.tsx @@ -24,13 +24,13 @@ import {KeyDescription} from 'sentry/components/searchQueryBuilder/tokens/filter import type {Section} from 'sentry/components/searchQueryBuilder/tokens/filterKeyListBox/types'; import { createRecentFilterOptionKey, + LOGIC_CATEGORY_VALUE, RECENT_SEARCH_CATEGORY_VALUE, } from 'sentry/components/searchQueryBuilder/tokens/filterKeyListBox/utils'; import type {Token, TokenResult} from 'sentry/components/searchSyntax/parser'; import {getKeyLabel, getKeyName} from 'sentry/components/searchSyntax/utils'; import {IconMegaphone} from 'sentry/icons'; import {t} from 'sentry/locale'; -import {space} from 'sentry/styles/space'; import {useFeedbackForm} from 'sentry/utils/useFeedbackForm'; import usePrevious from 'sentry/utils/usePrevious'; @@ -152,7 +152,10 @@ function useHighlightFirstOptionOnSectionChange({ state: ComboBoxState>; }) { const displayedListItems = useMemo(() => { - if (selectedSection === RECENT_SEARCH_CATEGORY_VALUE) { + if ( + selectedSection === RECENT_SEARCH_CATEGORY_VALUE || + selectedSection === LOGIC_CATEGORY_VALUE + ) { return [...state.collection].filter(item => !hiddenOptions.has(item.key)); } const options = state.collection.getChildren?.(selectedSection ?? sections[0]!.value); @@ -169,6 +172,7 @@ function useHighlightFirstOptionOnSectionChange({ if (selectedSection === previousSection) { return; } + const firstItem = displayedListItems[0]; if (firstItem) { state.selectionManager.setFocusedKey(firstItem.key); @@ -477,7 +481,7 @@ const SectionedOverlayFooter = styled('div')` display: flex; align-items: center; justify-content: flex-end; - padding: ${space(1)}; + padding: ${p => p.theme.space.md}; border-top: 1px solid ${p => p.theme.innerBorder}; `; @@ -486,8 +490,8 @@ const RecentFiltersPane = styled('ul')` display: flex; flex-wrap: wrap; background: ${p => p.theme.backgroundSecondary}; - padding: ${space(1)} 10px; - gap: ${space(0.25)}; + padding: ${p => p.theme.space.md} 10px; + gap: ${p => p.theme.space['2xs']}; border-bottom: 1px solid ${p => p.theme.innerBorder}; margin: 0; `; @@ -505,10 +509,10 @@ const DetailsPane = styled('div')` const SectionedListBoxTabPane = styled('div')` grid-area: tabs; - padding: ${space(0.5)}; + padding: ${p => p.theme.space.xs}; display: flex; flex-wrap: wrap; - gap: ${space(0.25)}; + gap: ${p => p.theme.space['2xs']}; border-bottom: 1px solid ${p => p.theme.innerBorder}; `; @@ -519,7 +523,7 @@ const RecentFilterPill = styled('li')` height: 22px; font-weight: ${p => p.theme.fontWeight.normal}; font-size: ${p => p.theme.fontSize.md}; - padding: 0 ${space(1.5)} 0 ${space(0.75)}; + padding: 0 ${p => p.theme.space.lg} 0 ${p => p.theme.space.sm}; background-color: ${p => p.theme.background}; box-shadow: inset 0 0 0 1px ${p => p.theme.innerBorder}; border-radius: ${p => p.theme.borderRadius} 0 0 ${p => p.theme.borderRadius}; @@ -536,7 +540,7 @@ const RecentFilterPill = styled('li')` background: linear-gradient( to left, ${p => p.theme.backgroundSecondary} 0 2px, - transparent ${space(2)} 100% + transparent ${p => p.theme.space.xl} 100% ); } `; @@ -551,7 +555,7 @@ const SectionButton = styled(Button)` text-align: left; font-weight: ${p => p.theme.fontWeight.normal}; font-size: ${p => p.theme.fontSize.sm}; - padding: 0 ${space(1.5)}; + padding: 0 ${p => p.theme.space.lg}; color: ${p => p.theme.subText}; border: 0; @@ -574,7 +578,7 @@ const EmptyState = styled('div')` align-items: center; justify-content: center; height: 100%; - padding: ${space(4)}; + padding: ${p => p.theme.space['3xl']}; text-align: center; color: ${p => p.theme.subText}; diff --git a/static/app/components/searchQueryBuilder/tokens/filterKeyListBox/types.tsx b/static/app/components/searchQueryBuilder/tokens/filterKeyListBox/types.tsx index 4ef5ba233fb856..cf2d0747fede02 100644 --- a/static/app/components/searchQueryBuilder/tokens/filterKeyListBox/types.tsx +++ b/static/app/components/searchQueryBuilder/tokens/filterKeyListBox/types.tsx @@ -58,6 +58,11 @@ export interface AskSeerConsentItem extends SelectOptionWithKey { value: string; } +export interface LogicFilterItem extends SelectOptionWithKey { + type: 'logic-filter'; + value: 'AND' | 'OR' | '(' | ')'; +} + export type SearchKeyItem = | KeySectionItem | KeyItem @@ -65,7 +70,8 @@ export type SearchKeyItem = | FilterValueItem | RawSearchFilterIsValueItem | AskSeerItem - | AskSeerConsentItem; + | AskSeerConsentItem + | LogicFilterItem; export type FilterKeyItem = | KeyItem @@ -76,7 +82,8 @@ export type FilterKeyItem = | FilterValueItem | RawSearchFilterIsValueItem | AskSeerItem - | AskSeerConsentItem; + | AskSeerConsentItem + | LogicFilterItem; export type Section = { label: ReactNode; diff --git a/static/app/components/searchQueryBuilder/tokens/filterKeyListBox/useFilterKeyListBox.tsx b/static/app/components/searchQueryBuilder/tokens/filterKeyListBox/useFilterKeyListBox.tsx index 7e48819e1a1af2..8f8031631fa4a4 100644 --- a/static/app/components/searchQueryBuilder/tokens/filterKeyListBox/useFilterKeyListBox.tsx +++ b/static/app/components/searchQueryBuilder/tokens/filterKeyListBox/useFilterKeyListBox.tsx @@ -19,16 +19,23 @@ import { ALL_CATEGORY_VALUE, createAskSeerConsentItem, createAskSeerItem, + createLogicFilterItem, createRecentFilterItem, createRecentFilterOptionKey, createRecentQueryItem, createSection, + LOGIC_CATEGORY, + LOGIC_CATEGORY_VALUE, RECENT_SEARCH_CATEGORY, RECENT_SEARCH_CATEGORY_VALUE, } from 'sentry/components/searchQueryBuilder/tokens/filterKeyListBox/utils'; import {itemIsSection} from 'sentry/components/searchQueryBuilder/tokens/utils'; import type {FieldDefinitionGetter} from 'sentry/components/searchQueryBuilder/types'; -import type {Token, TokenResult} from 'sentry/components/searchSyntax/parser'; +import type { + ParseResultToken, + Token, + TokenResult, +} from 'sentry/components/searchSyntax/parser'; import {getKeyName} from 'sentry/components/searchSyntax/utils'; import type {RecentSearch, TagCollection} from 'sentry/types/group'; import {trackAnalytics} from 'sentry/utils/analytics'; @@ -127,10 +134,16 @@ function useFilterKeyItems() { function useFilterKeySections({ recentSearches, + filterItem, }: { + filterItem: Node; recentSearches: RecentSearch[] | undefined; }) { - const {filterKeySections, query} = useSearchQueryBuilder(); + const {filterKeySections, query, disallowLogicalOperators} = useSearchQueryBuilder(); + const organization = useOrganization(); + const hasConditionalsInCombobox = organization.features.includes( + 'search-query-builder-conditionals-combobox-menus' + ); const sections = useMemo(() => { const definedSections = filterKeySections.map(section => ({ @@ -142,12 +155,34 @@ function useFilterKeySections({ return []; } + const isFirstItem = filterItem.key.toString().endsWith(':0'); if (recentSearches?.length && !query) { - return [RECENT_SEARCH_CATEGORY, ALL_CATEGORY, ...definedSections]; + const recentSearchesSections: Section[] = [ + RECENT_SEARCH_CATEGORY, + ALL_CATEGORY, + ...definedSections, + ]; + + if (!disallowLogicalOperators && !isFirstItem && hasConditionalsInCombobox) { + recentSearchesSections.push(LOGIC_CATEGORY); + } + return recentSearchesSections; } - return [ALL_CATEGORY, ...definedSections]; - }, [filterKeySections, query, recentSearches?.length]); + const customSections: Section[] = [ALL_CATEGORY, ...definedSections]; + if (!disallowLogicalOperators && !isFirstItem && hasConditionalsInCombobox) { + customSections.push(LOGIC_CATEGORY); + } + + return customSections; + }, [ + disallowLogicalOperators, + filterKeySections, + hasConditionalsInCombobox, + filterItem.key, + query, + recentSearches?.length, + ]); const [selectedSection, setSelectedSection] = useState( sections[0]?.value ?? '' @@ -164,7 +199,20 @@ function useFilterKeySections({ return {sections, selectedSection, setSelectedSection}; } -export function useFilterKeyListBox({filterValue}: {filterValue: string}) { + +const conditionalFilterItems = [ + createLogicFilterItem({value: 'AND'}), + createLogicFilterItem({value: 'OR'}), + createLogicFilterItem({value: '('}), + createLogicFilterItem({value: ')'}), +]; + +interface UseFilterKeyListBoxArgs { + filterItem: Node; + filterValue: string; +} + +export function useFilterKeyListBox({filterValue, filterItem}: UseFilterKeyListBoxArgs) { const { filterKeys, getFieldDefinition, @@ -173,18 +221,23 @@ export function useFilterKeyListBox({filterValue}: {filterValue: string}) { enableAISearch, gaveSeerConsent, currentInputValueRef, + disallowLogicalOperators, } = useSearchQueryBuilder(); const {sectionedItems} = useFilterKeyItems(); const recentFilters = useRecentSearchFilters(); const {data: recentSearches} = useRecentSearches(); const {sections, selectedSection, setSelectedSection} = useFilterKeySections({ recentSearches, + filterItem, }); const organization = useOrganization(); const hasAskSeerConsentFlowChanges = organization.features.includes( 'gen-ai-consent-flow-removal' ); + const hasConditionalsInCombobox = organization.features.includes( + 'search-query-builder-conditionals-combobox-menus' + ); const filterKeyMenuItems = useMemo(() => { const recentFilterItems = makeRecentFilterItems({recentFilters}); @@ -212,6 +265,14 @@ export function useFilterKeyListBox({filterValue}: {filterValue: string}) { ]; } + if ( + !disallowLogicalOperators && + selectedSection === LOGIC_CATEGORY_VALUE && + hasConditionalsInCombobox + ) { + return [...askSeerItem, ...conditionalFilterItems]; + } + const filteredByCategory = sectionedItems.filter(item => { if (itemIsSection(item)) { if (selectedSection === ALL_CATEGORY_VALUE) { @@ -225,11 +286,13 @@ export function useFilterKeyListBox({filterValue}: {filterValue: string}) { return [...askSeerItem, ...recentFilterItems, ...filteredByCategory]; }, [ + disallowLogicalOperators, enableAISearch, filterKeys, gaveSeerConsent, getFieldDefinition, hasAskSeerConsentFlowChanges, + hasConditionalsInCombobox, recentFilters, recentSearches, sectionedItems, diff --git a/static/app/components/searchQueryBuilder/tokens/filterKeyListBox/utils.tsx b/static/app/components/searchQueryBuilder/tokens/filterKeyListBox/utils.tsx index c904b25114d326..ad93d49d57fc0c 100644 --- a/static/app/components/searchQueryBuilder/tokens/filterKeyListBox/utils.tsx +++ b/static/app/components/searchQueryBuilder/tokens/filterKeyListBox/utils.tsx @@ -11,6 +11,7 @@ import type { FilterValueItem, KeyItem, KeySectionItem, + LogicFilterItem, RawSearchFilterIsValueItem, RawSearchItem, RecentQueryItem, @@ -36,12 +37,14 @@ import {escapeFilterValue} from 'sentry/utils/tokenizeSearch'; export const ALL_CATEGORY_VALUE = '__all'; export const RECENT_SEARCH_CATEGORY_VALUE = '__recent_searches'; +export const LOGIC_CATEGORY_VALUE = '__logic_filters'; export const ALL_CATEGORY = {value: ALL_CATEGORY_VALUE, label: t('All')}; export const RECENT_SEARCH_CATEGORY = { value: RECENT_SEARCH_CATEGORY_VALUE, label: t('Recent'), }; +export const LOGIC_CATEGORY = {value: LOGIC_CATEGORY_VALUE, label: t('Logic')}; const RECENT_FILTER_KEY_PREFIX = '__recent_filter_key__'; const RECENT_QUERY_KEY_PREFIX = '__recent_search__'; @@ -246,6 +249,22 @@ export function createAskSeerConsentItem(): AskSeerConsentItem { }; } +export function createLogicFilterItem({ + value, +}: { + value: 'AND' | 'OR' | '(' | ')'; +}): LogicFilterItem { + return { + key: getEscapedKey(value), + type: 'logic-filter' as const, + value, + label: value, + textValue: value, + hideCheck: true, + showDetailsInOverlay: true, + }; +} + const SearchItemLabel = styled('div')` color: ${p => p.theme.subText}; white-space: nowrap; diff --git a/static/app/components/searchQueryBuilder/tokens/freeText.tsx b/static/app/components/searchQueryBuilder/tokens/freeText.tsx index 9ab5f98543e21a..9c10afa72e618a 100644 --- a/static/app/components/searchQueryBuilder/tokens/freeText.tsx +++ b/static/app/components/searchQueryBuilder/tokens/freeText.tsx @@ -301,6 +301,7 @@ function SearchQueryBuilderInputInternal({ const {customMenu, sectionItems, maxOptions, onKeyDownCapture, handleOptionSelected} = useFilterKeyListBox({ + filterItem: item, filterValue, }); const sortedFilteredItems = useSortedFilterKeyItems({ @@ -440,6 +441,18 @@ function SearchQueryBuilderInputInternal({ return; } + if (option.type === 'logic-filter') { + dispatch({ + type: 'UPDATE_FREE_TEXT_ON_SELECT', + tokens: [token], + text: option.value, + shouldCommitQuery: true, + focusOverride: calculateNextFocusForInsertedToken(item), + }); + resetInputValue(); + return; + } + if (option.type === 'raw-search') { dispatch({ type: 'UPDATE_FREE_TEXT_ON_SELECT',