// eslint-disable-next-line simple-import-sort/imports import { filterTypeConfig, interchangeableFilterOperators, SearchConfig, TermOperator, Token, TokenResult, } from 'sentry/components/searchSyntax/parser'; import { IconArrow, IconClock, IconDelete, IconExclamation, IconStar, IconTag, IconToggle, IconUser, } from 'sentry/icons'; import {t} from 'sentry/locale'; import { AutocompleteGroup, ItemType, SearchGroup, SearchItem, Shortcut, ShortcutType, } from './types'; import {TagCollection} from 'sentry/types'; import {FieldKind, FieldValueType, getFieldDefinition} from 'sentry/utils/fields'; import {CustomMeasurementCollection} from 'sentry/utils/customMeasurements/customMeasurements'; export function addSpace(query = '') { if (query.length !== 0 && query[query.length - 1] !== ' ') { return query + ' '; } return query; } export function removeSpace(query = '') { if (query[query.length - 1] === ' ') { return query.slice(0, query.length - 1); } return query; } /** * Given a query, and the current cursor position, return the string-delimiting * index of the search term designated by the cursor. */ export function getLastTermIndex(query: string, cursor: number) { // TODO: work with quoted-terms const cursorOffset = query.slice(cursor).search(/\s|$/); return cursor + (cursorOffset === -1 ? 0 : cursorOffset); } /** * Returns an array of query terms, including incomplete terms * * e.g. ["is:unassigned", "browser:\"Chrome 33.0\"", "assigned"] */ export function getQueryTerms(query: string, cursor: number) { return query.slice(0, cursor).match(/\S+:"[^"]*"?|\S+/g); } function getTitleForType(type: ItemType) { if (type === ItemType.TAG_VALUE) { return t('Values'); } if (type === ItemType.RECENT_SEARCH) { return t('Recent Searches'); } if (type === ItemType.DEFAULT) { return t('Common Search Terms'); } if (type === ItemType.TAG_OPERATOR) { return t('Operator Helpers'); } if (type === ItemType.PROPERTY) { return t('Properties'); } return t('Keys'); } function getIconForTypeAndTag(type: ItemType, tagName: string) { if (type === ItemType.RECENT_SEARCH) { return ; } if (type === ItemType.DEFAULT) { return ; } // Change based on tagName and default to "icon-tag" switch (tagName) { case 'is': return ; case 'assigned': case 'bookmarks': return ; case 'firstSeen': case 'lastSeen': case 'event.timestamp': return ; default: return ; } } const filterSearchItems = ( searchItems: SearchItem[], recentSearchItems?: SearchItem[], maxSearchItems?: number, queryCharsLeft?: number ) => { if (maxSearchItems && maxSearchItems > 0) { searchItems = searchItems.filter( (value: SearchItem, index: number) => index < maxSearchItems || value.ignoreMaxSearchItems ); } if (queryCharsLeft || queryCharsLeft === 0) { searchItems = searchItems.flatMap(item => { if (!item.children) { if (!item.value || item.value.length <= queryCharsLeft) { return [item]; } return []; } const newItem = { ...item, children: item.children.filter( child => !child.value || child.value.length <= queryCharsLeft ), }; if (newItem.children.length === 0) { return []; } return [newItem]; }); searchItems = searchItems.filter( (value: SearchItem) => !value.value || value.value.length <= queryCharsLeft ); if (recentSearchItems) { recentSearchItems = recentSearchItems.filter( (value: SearchItem) => !value.value || value.value.length <= queryCharsLeft ); } } return {searchItems, recentSearchItems}; }; export function createSearchGroups( searchItems: SearchItem[], recentSearchItems: SearchItem[] | undefined, tagName: string, type: ItemType, maxSearchItems?: number, queryCharsLeft?: number, isDefaultState?: boolean ) { const fieldDefinition = getFieldDefinition(tagName); const activeSearchItem = 0; const {searchItems: filteredSearchItems, recentSearchItems: filteredRecentSearchItems} = filterSearchItems(searchItems, recentSearchItems, maxSearchItems, queryCharsLeft); const searchGroup: SearchGroup = { title: getTitleForType(type), type: type === ItemType.INVALID_TAG ? type : 'header', icon: getIconForTypeAndTag(type, tagName), children: [...filteredSearchItems], }; const recentSearchGroup: SearchGroup | undefined = filteredRecentSearchItems && filteredRecentSearchItems.length > 0 ? { title: t('Recent Searches'), type: 'header', icon: , children: [...filteredRecentSearchItems], } : undefined; if (searchGroup.children && !!searchGroup.children.length) { searchGroup.children[activeSearchItem] = { ...searchGroup.children[activeSearchItem], }; } const flatSearchItems = filteredSearchItems.flatMap(item => { if (item.children) { if (!item.value) { return [...item.children]; } return [item, ...item.children]; } return [item]; }); if (fieldDefinition?.valueType === FieldValueType.DATE) { if (type === ItemType.TAG_OPERATOR) { return { searchGroups: [], flatSearchItems: [], activeSearchItem: -1, }; } } if (isDefaultState) { // Recent searches first in default state. return { searchGroups: [...(recentSearchGroup ? [recentSearchGroup] : []), searchGroup], flatSearchItems: [ ...(recentSearchItems ? recentSearchItems : []), ...flatSearchItems, ], activeSearchItem: -1, }; } return { searchGroups: [searchGroup, ...(recentSearchGroup ? [recentSearchGroup] : [])], flatSearchItems: [ ...flatSearchItems, ...(recentSearchItems ? recentSearchItems : []), ], activeSearchItem: -1, }; } export function generateOperatorEntryMap(tag: string) { return { [TermOperator.Default]: { type: ItemType.TAG_OPERATOR, value: ':', desc: `${tag}:${t('[value]')}`, documentation: 'is equal to', }, [TermOperator.GreaterThanEqual]: { type: ItemType.TAG_OPERATOR, value: ':>=', desc: `${tag}:${t('>=[value]')}`, documentation: 'is greater than or equal to', }, [TermOperator.LessThanEqual]: { type: ItemType.TAG_OPERATOR, value: ':<=', desc: `${tag}:${t('<=[value]')}`, documentation: 'is less than or equal to', }, [TermOperator.GreaterThan]: { type: ItemType.TAG_OPERATOR, value: ':>', desc: `${tag}:${t('>[value]')}`, documentation: 'is greater than', }, [TermOperator.LessThan]: { type: ItemType.TAG_OPERATOR, value: ':<', desc: `${tag}:${t('<[value]')}`, documentation: 'is less than', }, [TermOperator.Equal]: { type: ItemType.TAG_OPERATOR, value: ':=', desc: `${tag}:${t('=[value]')}`, documentation: 'is equal to', }, [TermOperator.NotEqual]: { type: ItemType.TAG_OPERATOR, value: '!:', desc: `!${tag}:${t('[value]')}`, documentation: 'is not equal to', }, }; } export function getValidOps( filterToken: TokenResult ): readonly TermOperator[] { // If the token is invalid we want to use the possible expected types as our filter type const validTypes = filterToken.invalid?.expectedType ?? [filterToken.filter]; // Determine any interchangeable filter types for our valid types const interchangeableTypes = validTypes.map( type => interchangeableFilterOperators[type] ?? [] ); // Combine all types const allValidTypes = [...new Set([...validTypes, ...interchangeableTypes.flat()])]; // Find all valid operations const validOps = new Set( allValidTypes.map(type => filterTypeConfig[type].validOps).flat() ); return [...validOps]; } export const shortcuts: Shortcut[] = [ { text: 'Delete', shortcutType: ShortcutType.Delete, hotkeys: { actual: 'ctrl+option+backspace', }, icon: , canRunShortcut: token => { return token?.type === Token.Filter; }, }, { text: 'Exclude', shortcutType: ShortcutType.Negate, hotkeys: { actual: 'ctrl+option+1', }, icon: , canRunShortcut: token => { return token?.type === Token.Filter && !token.negated; }, }, { text: 'Include', shortcutType: ShortcutType.Negate, hotkeys: { actual: 'ctrl+option+1', }, icon: , canRunShortcut: token => { return token?.type === Token.Filter && token.negated; }, }, { text: 'Previous', shortcutType: ShortcutType.Previous, hotkeys: { actual: 'ctrl+option+left', }, icon: , canRunShortcut: (token, count) => { return count > 1 || (count > 0 && token?.type !== Token.Filter); }, }, { text: 'Next', shortcutType: ShortcutType.Next, hotkeys: { actual: 'ctrl+option+right', }, icon: , canRunShortcut: (token, count) => { return count > 1 || (count > 0 && token?.type !== Token.Filter); }, }, ]; const getItemTitle = (key: string, kind: FieldKind) => { if (kind === FieldKind.FUNCTION) { // Replace the function innards with ... for cleanliness return key.replace(/\(.*\)/g, '(...)'); } return key; }; /** * Groups tag keys based on the "." character in their key. * For example, "device.arch" and "device.name" will be grouped together as children of "device", a non-interactive parent. * The parent will become interactive if there exists a key "device". */ export const getTagItemsFromKeys = (tagKeys: string[], supportedTags: TagCollection) => { return [...tagKeys].reduce((groups, key) => { const keyWithColon = `${key}:`; const sections = key.split('.'); const definition = supportedTags[key]?.kind === FieldKind.FUNCTION ? getFieldDefinition(key.split('(')[0]) : getFieldDefinition(key); const kind = supportedTags[key]?.kind ?? definition?.kind ?? FieldKind.FIELD; const item: SearchItem = { value: keyWithColon, title: getItemTitle(key, kind), documentation: definition?.desc ?? '-', kind, deprecated: definition?.deprecated, featureFlag: definition?.featureFlag, }; const lastGroup = groups.at(-1); const [title] = sections; if (kind !== FieldKind.FUNCTION && lastGroup) { if (lastGroup.children && lastGroup.title === title) { lastGroup.children.push(item); return groups; } if (lastGroup.title && lastGroup.title.split('.')[0] === title) { if (lastGroup.title === title) { return [ ...groups.slice(0, -1), { title, value: lastGroup.value, documentation: lastGroup.documentation, kind: lastGroup.kind, children: [item], }, ]; } // Add a blank parent if the last group's full key is not the same as the title return [ ...groups.slice(0, -1), { title, value: null, documentation: '-', kind: lastGroup.kind, children: [lastGroup, item], }, ]; } } return [...groups, item]; }, []); }; /** * Sets an item as active within a search group array and returns new search groups without mutating. * the item is compared via value, so this function assumes that each value is unique. */ export const getSearchGroupWithItemMarkedActive = ( searchGroups: SearchGroup[], currentItem: SearchItem, active: boolean ) => { return searchGroups.map(group => ({ ...group, children: group.children?.map(item => { if (item.value === currentItem.value) { return { ...item, active, }; } if (item.children && item.children.length > 0) { return { ...item, children: item.children.map(child => { if (child.value === currentItem.value) { return { ...child, active, }; } return child; }), }; } return item; }), })); }; /** * Filter tag keys based on the query and the key, description, and associated keywords of each tag. */ export const filterKeysFromQuery = (tagKeys: string[], searchTerm: string): string[] => tagKeys .flatMap(key => { const keyWithoutFunctionPart = key.replaceAll(/\(.*\)/g, ''); const definition = getFieldDefinition(keyWithoutFunctionPart); const lowerCasedSearchTerm = searchTerm.toLocaleLowerCase(); const combinedKeywords = [ ...(definition?.desc ? [definition.desc] : []), ...(definition?.keywords ?? []), ] .join(' ') .toLocaleLowerCase(); const matchedInKey = keyWithoutFunctionPart.includes(lowerCasedSearchTerm); const matchedInKeywords = combinedKeywords.includes(lowerCasedSearchTerm); if (!matchedInKey && !matchedInKeywords) { return []; } return [{matchedInKey, matchedInKeywords, key}]; }) .sort((a, b) => { // Sort by matched in key first, then by matched in keywords if (a.matchedInKey && !b.matchedInKey) { return -1; } if (b.matchedInKey && !a.matchedInKey) { return 1; } return a.key < b.key ? -1 : 1; }) .map(({key}) => key); const DATE_SUGGESTED_VALUES = [ { title: t('Last hour'), value: '-1h', desc: '-1h', type: ItemType.TAG_VALUE, }, { title: t('Last 24 hours'), value: '-24h', desc: '-24h', type: ItemType.TAG_VALUE, }, { title: t('Last 7 days'), value: '-7d', desc: '-7d', type: ItemType.TAG_VALUE, }, { title: t('Last 14 days'), value: '-14d', desc: '-14d', type: ItemType.TAG_VALUE, }, { title: t('Last 30 days'), value: '-30d', desc: '-30d', type: ItemType.TAG_VALUE, }, { title: t('After a custom datetime'), value: '>', desc: '>YYYY-MM-DDThh:mm:ss', type: ItemType.TAG_VALUE_ISO_DATE, }, { title: t('Before a custom datetime'), value: '<', desc: ' { return [ { searchItems: DATE_SUGGESTED_VALUES, recentSearchItems: [], tagName, type: ItemType.TAG_VALUE, }, ]; }; export const getSearchConfigFromCustomPerformanceMetrics = ( customPerformanceMetrics?: CustomMeasurementCollection ): Partial => { const searchConfigMap: Record = { sizeKeys: [], durationKeys: [], percentageKeys: [], numericKeys: [], }; if (customPerformanceMetrics) { Object.keys(customPerformanceMetrics).forEach(metricName => { const {fieldType} = customPerformanceMetrics[metricName]; switch (fieldType) { case 'size': searchConfigMap.sizeKeys.push(metricName); break; case 'duration': searchConfigMap.durationKeys.push(metricName); break; case 'percentage': searchConfigMap.percentageKeys.push(metricName); break; default: searchConfigMap.numericKeys.push(metricName); } }); } const searchConfig = { sizeKeys: new Set(searchConfigMap.sizeKeys), durationKeys: new Set(searchConfigMap.durationKeys), percentageKeys: new Set(searchConfigMap.percentageKeys), numericKeys: new Set(searchConfigMap.numericKeys), }; return searchConfig; };