diff --git a/static/app/views/explore/components/queryTokens.tsx b/static/app/views/explore/components/seerComboBox/queryTokens.tsx similarity index 94% rename from static/app/views/explore/components/queryTokens.tsx rename to static/app/views/explore/components/seerComboBox/queryTokens.tsx index 9b22595b0c0884..94becb4cc898da 100644 --- a/static/app/views/explore/components/queryTokens.tsx +++ b/static/app/views/explore/components/seerComboBox/queryTokens.tsx @@ -3,11 +3,10 @@ import styled from '@emotion/styled'; import {ProvidedFormattedQuery} from 'sentry/components/searchQueryBuilder/formattedQuery'; import {parseQueryBuilderValue} from 'sentry/components/searchQueryBuilder/utils'; import {t} from 'sentry/locale'; -import {space} from 'sentry/styles/space'; import {getFieldDefinition} from 'sentry/utils/fields'; import type {ChartType} from 'sentry/views/insights/common/components/chart'; -interface QueryTokensProps { +export interface QueryTokensProps { groupBys?: string[]; query?: string; sort?: string; @@ -91,14 +90,14 @@ export default QueryTokens; const TokenContainer = styled('div')` display: flex; - gap: ${space(1)}; - padding: ${space(1)}; + gap: ${p => p.theme.space.md}; + padding: ${p => p.theme.space.md}; `; const Token = styled('span')` display: flex; flex-direction: row; - gap: ${space(0.5)}; + gap: ${p => p.theme.space.xs}; overflow: hidden; flex-wrap: wrap; align-items: center; @@ -116,7 +115,7 @@ const ExploreParamTitle = styled('span')` const ExploreVisualizes = styled('span')` font-size: ${p => p.theme.form.sm.fontSize}; background: ${p => p.theme.background}; - padding: ${space(0.25)} ${space(0.5)}; + padding: ${p => p.theme.space['2xs']} ${p => p.theme.space.xs}; border: 1px solid ${p => p.theme.innerBorder}; border-radius: ${p => p.theme.borderRadius}; height: 24px; diff --git a/static/app/views/explore/components/seerComboBox/seerComboBox.tsx b/static/app/views/explore/components/seerComboBox/seerComboBox.tsx index 602a42c359bb52..db7fda23726d0b 100644 --- a/static/app/views/explore/components/seerComboBox/seerComboBox.tsx +++ b/static/app/views/explore/components/seerComboBox/seerComboBox.tsx @@ -17,18 +17,21 @@ import {trackAnalytics} from 'sentry/utils/analytics'; import {useFeedbackForm} from 'sentry/utils/useFeedbackForm'; import useOrganization from 'sentry/utils/useOrganization'; import useOverlay from 'sentry/utils/useOverlay'; -import QueryTokens from 'sentry/views/explore/components/queryTokens'; import { type SeerSearchItem, useApplySeerSearchQuery, useSeerSearch, } from 'sentry/views/explore/components/seerComboBox/hooks'; +import QueryTokens from 'sentry/views/explore/components/seerComboBox/queryTokens'; import {SeerSearchHeader} from 'sentry/views/explore/components/seerComboBox/seerSearchHeader'; import {SeerSearchListBox} from 'sentry/views/explore/components/seerComboBox/seerSearchListBox'; import {SeerSearchPopover} from 'sentry/views/explore/components/seerComboBox/seerSearchPopover'; import {SeerSearchSkeleton} from 'sentry/views/explore/components/seerComboBox/seerSearchSkeleton'; +import { + formatQueryToNaturalLanguage, + generateQueryTokensString, +} from 'sentry/views/explore/components/seerComboBox/utils'; import {useTraceExploreAiQuerySetup} from 'sentry/views/explore/hooks/useTraceExploreAiQuerySetup'; -import {formatQueryToNaturalLanguage} from 'sentry/views/explore/utils'; // The menu size can change from things like loading states, long options, // or custom menus like a date picker. This hook ensures that the overlay @@ -247,8 +250,20 @@ export function SeerComboBox({initialQuery, ...props}: SeerComboBoxProps) { ); } + const readableQuery = generateQueryTokensString({ + groupBys: item.groupBys, + query: item.query, + sort: item.sort, + statsPeriod: item.statsPeriod, + visualizations: item.visualizations, + }); + return ( - + - {[...state.collection].map(item => ( - - ))} + {[...state.collection].map(item => { + return ( + + ); + })} ); } @@ -26,14 +33,15 @@ export function SeerSearchListBox(props: SeerSearchListBoxProps) { interface SeerSearchOptionProps { item: Node; state: ListState; + label?: string; } -function SeerSearchOption({item, state}: SeerSearchOptionProps) { +function SeerSearchOption({item, state, label}: SeerSearchOptionProps) { const ref = useRef(null); const {optionProps, isFocused} = useOption({key: item.key}, state, ref); return ( - + {item.rendered} ); diff --git a/static/app/views/explore/components/seerComboBox/utils.ts b/static/app/views/explore/components/seerComboBox/utils.ts new file mode 100644 index 00000000000000..29af0a4f83a8b8 --- /dev/null +++ b/static/app/views/explore/components/seerComboBox/utils.ts @@ -0,0 +1,108 @@ +import type {QueryTokensProps} from 'sentry/views/explore/components/seerComboBox/queryTokens'; + +function formatToken(token: string): string { + const isNegated = token.startsWith('!') && token.includes(':'); + const actualToken = isNegated ? token.slice(1) : token; + + const operators = [ + [':>=', 'greater than or equal to'], + [':<=', 'less than or equal to'], + [':!=', 'not'], + [':>', 'greater than'], + [':<', 'less than'], + ['>=', 'greater than or equal to'], + ['<=', 'less than or equal to'], + ['!=', 'not'], + ['!:', 'not'], + ['>', 'greater than'], + ['<', 'less than'], + [':', ''], + ] as const; + + for (const [op, desc] of operators) { + if (actualToken.includes(op)) { + const [key, value] = actualToken.split(op); + const cleanKey = key?.trim() || ''; + const cleanVal = value?.trim() || ''; + + const negation = isNegated ? 'not ' : ''; + const description = desc ? `${negation}${desc}` : negation ? 'not' : ''; + + return `${cleanKey} is ${description} ${cleanVal}`.replace(/\s+/g, ' ').trim(); + } + } + + return token; +} + +export function formatQueryToNaturalLanguage(query: string): string { + if (!query.trim()) return ''; + const tokens = query.match(/(?:[^\s"]+|"[^"]*")+/g) || []; + const formattedTokens = tokens.map(formatToken); + + return formattedTokens.reduce((result, token, index) => { + if (index === 0) return token; + + const currentOriginalToken = tokens[index] || ''; + const prevOriginalToken = tokens[index - 1] || ''; + + const isLogicalOp = token.toUpperCase() === 'AND' || token.toUpperCase() === 'OR'; + const prevIsLogicalOp = + formattedTokens[index - 1]?.toUpperCase() === 'AND' || + formattedTokens[index - 1]?.toUpperCase() === 'OR'; + + if (isLogicalOp || prevIsLogicalOp) { + return `${result} ${token}`; + } + + const isCurrentFilter = /[:>== 0) { + const vizParts = visualizations.flatMap(visualization => + visualization.yAxes.map(yAxis => yAxis) + ); + if (vizParts.length > 0) { + const vizText = vizParts.length === 1 ? vizParts[0] : vizParts.join(', '); + parts.push(`visualizations are '${vizText}'`); + } + } + + if (groupBys && groupBys.length > 0) { + const groupByText = groupBys.length === 1 ? groupBys[0] : groupBys.join(', '); + parts.push(`groupBys are '${groupByText}'`); + } + + if (statsPeriod && statsPeriod.length > 0) { + parts.push(`time range is '${statsPeriod}'`); + } + + if (sort && sort.length > 0) { + const sortText = sort[0] === '-' ? `${sort.slice(1)} Desc` : `${sort} Asc`; + parts.push(`sort is '${sortText}'`); + } + + return parts.length > 0 ? parts.join(', ') : 'No query parameters set'; +} diff --git a/static/app/views/explore/utils.tsx b/static/app/views/explore/utils.tsx index 4010eb867305ba..c9fd34a66f9597 100644 --- a/static/app/views/explore/utils.tsx +++ b/static/app/views/explore/utils.tsx @@ -610,72 +610,6 @@ function normalizeKey(key: string): string { return key.startsWith('!') ? key.slice(1) : key; } -export function formatQueryToNaturalLanguage(query: string): string { - if (!query.trim()) return ''; - const tokens = query.match(/(?:[^\s"]+|"[^"]*")+/g) || []; - const formattedTokens = tokens.map(formatToken); - - return formattedTokens.reduce((result, token, index) => { - if (index === 0) return token; - - const currentOriginalToken = tokens[index] || ''; - const prevOriginalToken = tokens[index - 1] || ''; - - const isLogicalOp = token.toUpperCase() === 'AND' || token.toUpperCase() === 'OR'; - const prevIsLogicalOp = - formattedTokens[index - 1]?.toUpperCase() === 'AND' || - formattedTokens[index - 1]?.toUpperCase() === 'OR'; - - if (isLogicalOp || prevIsLogicalOp) { - return `${result} ${token}`; - } - - const isCurrentFilter = /[:>===', 'greater than or equal to'], - [':<=', 'less than or equal to'], - [':!=', 'not'], - [':>', 'greater than'], - [':<', 'less than'], - ['>=', 'greater than or equal to'], - ['<=', 'less than or equal to'], - ['!=', 'not'], - ['!:', 'not'], - ['>', 'greater than'], - ['<', 'less than'], - [':', ''], - ] as const; - - for (const [op, desc] of operators) { - if (actualToken.includes(op)) { - const [key, value] = actualToken.split(op); - const cleanKey = key?.trim() || ''; - const cleanVal = value?.trim() || ''; - - const negation = isNegated ? 'not ' : ''; - const description = desc ? `${negation}${desc}` : negation ? 'not' : ''; - - return `${cleanKey} is ${description} ${cleanVal}`.replace(/\s+/g, ' ').trim(); - } - } - - return token; -} - export function prettifyAggregation(aggregation: string): string | null { if (isEquation(aggregation)) { const expression = new Expression(stripEquationPrefix(aggregation));