utils.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654
  1. // eslint-disable-next-line simple-import-sort/imports
  2. import {
  3. filterTypeConfig,
  4. interchangeableFilterOperators,
  5. SearchConfig,
  6. TermOperator,
  7. Token,
  8. TokenResult,
  9. } from 'sentry/components/searchSyntax/parser';
  10. import {
  11. IconArrow,
  12. IconClock,
  13. IconDelete,
  14. IconExclamation,
  15. IconStar,
  16. IconTag,
  17. IconToggle,
  18. IconUser,
  19. } from 'sentry/icons';
  20. import {t} from 'sentry/locale';
  21. import {
  22. AutocompleteGroup,
  23. ItemType,
  24. SearchGroup,
  25. SearchItem,
  26. Shortcut,
  27. ShortcutType,
  28. invalidTypes,
  29. } from './types';
  30. import {TagCollection} from 'sentry/types';
  31. import {FieldKind, FieldValueType, getFieldDefinition} from 'sentry/utils/fields';
  32. import {CustomMeasurementCollection} from 'sentry/utils/customMeasurements/customMeasurements';
  33. export function addSpace(query = '') {
  34. if (query.length !== 0 && query[query.length - 1] !== ' ') {
  35. return query + ' ';
  36. }
  37. return query;
  38. }
  39. export function removeSpace(query = '') {
  40. if (query[query.length - 1] === ' ') {
  41. return query.slice(0, query.length - 1);
  42. }
  43. return query;
  44. }
  45. function getTitleForType(type: ItemType) {
  46. if (type === ItemType.TAG_VALUE) {
  47. return t('Values');
  48. }
  49. if (type === ItemType.RECENT_SEARCH) {
  50. return t('Recent Searches');
  51. }
  52. if (type === ItemType.DEFAULT) {
  53. return t('Common Search Terms');
  54. }
  55. if (type === ItemType.TAG_OPERATOR) {
  56. return t('Operator Helpers');
  57. }
  58. if (type === ItemType.PROPERTY) {
  59. return t('Properties');
  60. }
  61. return t('Keys');
  62. }
  63. function getIconForTypeAndTag(type: ItemType, tagName: string) {
  64. if (type === ItemType.RECENT_SEARCH) {
  65. return <IconClock size="xs" />;
  66. }
  67. if (type === ItemType.DEFAULT) {
  68. return <IconStar size="xs" />;
  69. }
  70. // Change based on tagName and default to "icon-tag"
  71. switch (tagName) {
  72. case 'is':
  73. return <IconToggle size="xs" />;
  74. case 'assigned':
  75. case 'bookmarks':
  76. return <IconUser size="xs" />;
  77. case 'firstSeen':
  78. case 'lastSeen':
  79. case 'event.timestamp':
  80. return <IconClock size="xs" />;
  81. default:
  82. return <IconTag size="xs" />;
  83. }
  84. }
  85. const filterSearchItems = (
  86. searchItems: SearchItem[],
  87. recentSearchItems?: SearchItem[],
  88. maxSearchItems?: number,
  89. queryCharsLeft?: number
  90. ): {recentSearchItems: SearchItem[] | undefined; searchItems: SearchItem[]} => {
  91. if (maxSearchItems && maxSearchItems > 0) {
  92. searchItems = searchItems.filter(
  93. (value: SearchItem, index: number) =>
  94. index < maxSearchItems || value.ignoreMaxSearchItems
  95. );
  96. }
  97. if (queryCharsLeft || queryCharsLeft === 0) {
  98. searchItems = searchItems.flatMap(item => {
  99. if (!item.children) {
  100. if (!item.value || item.value.length <= queryCharsLeft) {
  101. return [item];
  102. }
  103. return [];
  104. }
  105. const newItem = {
  106. ...item,
  107. children: item.children.filter(
  108. child => !child.value || child.value.length <= queryCharsLeft
  109. ),
  110. };
  111. if (newItem.children.length === 0) {
  112. return [];
  113. }
  114. return [newItem];
  115. });
  116. searchItems = searchItems.filter(
  117. value => !value.value || value.value.length <= queryCharsLeft
  118. );
  119. if (recentSearchItems) {
  120. recentSearchItems = recentSearchItems.filter(
  121. value => !value.value || value.value.length <= queryCharsLeft
  122. );
  123. }
  124. }
  125. return {searchItems, recentSearchItems};
  126. };
  127. interface SearchGroups {
  128. activeSearchItem: number;
  129. flatSearchItems: SearchItem[];
  130. searchGroups: SearchGroup[];
  131. }
  132. export function createSearchGroups(
  133. searchItems: SearchItem[],
  134. recentSearchItems: SearchItem[] | undefined,
  135. tagName: string,
  136. type: ItemType,
  137. maxSearchItems?: number,
  138. queryCharsLeft?: number,
  139. isDefaultState?: boolean,
  140. defaultSearchGroup?: SearchGroup,
  141. fieldDefinitionGetter: typeof getFieldDefinition = getFieldDefinition
  142. ): SearchGroups {
  143. const fieldDefinition = fieldDefinitionGetter(tagName);
  144. const activeSearchItem = 0;
  145. const {searchItems: filteredSearchItems, recentSearchItems: filteredRecentSearchItems} =
  146. filterSearchItems(searchItems, recentSearchItems, maxSearchItems, queryCharsLeft);
  147. const searchGroup: SearchGroup = {
  148. title: getTitleForType(type),
  149. type: invalidTypes.includes(type) ? type : 'header',
  150. icon: getIconForTypeAndTag(type, tagName),
  151. children: filteredSearchItems,
  152. };
  153. const recentSearchGroup: SearchGroup | undefined =
  154. filteredRecentSearchItems && filteredRecentSearchItems.length > 0
  155. ? {
  156. title: t('Recent Searches'),
  157. type: 'header',
  158. icon: <IconClock size="xs" />,
  159. children: [...filteredRecentSearchItems],
  160. }
  161. : undefined;
  162. if (searchGroup.children && !!searchGroup.children.length) {
  163. searchGroup.children[activeSearchItem] = {
  164. ...searchGroup.children[activeSearchItem],
  165. };
  166. }
  167. const flatSearchItems = filteredSearchItems.flatMap(item => {
  168. if (item.children) {
  169. if (!item.value) {
  170. return [...item.children];
  171. }
  172. return [item, ...item.children];
  173. }
  174. return [item];
  175. });
  176. if (fieldDefinition?.valueType === FieldValueType.DATE) {
  177. if (type === ItemType.TAG_OPERATOR) {
  178. return {
  179. searchGroups: [],
  180. flatSearchItems: [],
  181. activeSearchItem: -1,
  182. };
  183. }
  184. }
  185. if (isDefaultState) {
  186. // Recent searches first in default state.
  187. return {
  188. searchGroups: [
  189. ...(recentSearchGroup ? [recentSearchGroup] : []),
  190. ...(defaultSearchGroup ? [defaultSearchGroup] : []),
  191. searchGroup,
  192. ],
  193. flatSearchItems: [
  194. ...(recentSearchItems ? recentSearchItems : []),
  195. ...(defaultSearchGroup ? defaultSearchGroup.children : []),
  196. ...flatSearchItems,
  197. ],
  198. activeSearchItem: -1,
  199. };
  200. }
  201. return {
  202. searchGroups: [searchGroup, ...(recentSearchGroup ? [recentSearchGroup] : [])],
  203. flatSearchItems: [
  204. ...flatSearchItems,
  205. ...(recentSearchItems ? recentSearchItems : []),
  206. ],
  207. activeSearchItem: -1,
  208. };
  209. }
  210. export function generateOperatorEntryMap(tag: string) {
  211. return {
  212. [TermOperator.DEFAULT]: {
  213. type: ItemType.TAG_OPERATOR,
  214. value: ':',
  215. desc: `${tag}:${t('[value]')}`,
  216. documentation: 'is equal to',
  217. },
  218. [TermOperator.GREATER_THAN_EQUAL]: {
  219. type: ItemType.TAG_OPERATOR,
  220. value: ':>=',
  221. desc: `${tag}:${t('>=[value]')}`,
  222. documentation: 'is greater than or equal to',
  223. },
  224. [TermOperator.LESS_THAN_EQUAL]: {
  225. type: ItemType.TAG_OPERATOR,
  226. value: ':<=',
  227. desc: `${tag}:${t('<=[value]')}`,
  228. documentation: 'is less than or equal to',
  229. },
  230. [TermOperator.GREATER_THAN]: {
  231. type: ItemType.TAG_OPERATOR,
  232. value: ':>',
  233. desc: `${tag}:${t('>[value]')}`,
  234. documentation: 'is greater than',
  235. },
  236. [TermOperator.LESS_THAN]: {
  237. type: ItemType.TAG_OPERATOR,
  238. value: ':<',
  239. desc: `${tag}:${t('<[value]')}`,
  240. documentation: 'is less than',
  241. },
  242. [TermOperator.EQUAL]: {
  243. type: ItemType.TAG_OPERATOR,
  244. value: ':=',
  245. desc: `${tag}:${t('=[value]')}`,
  246. documentation: 'is equal to',
  247. },
  248. [TermOperator.NOT_EQUAL]: {
  249. type: ItemType.TAG_OPERATOR,
  250. value: '!:',
  251. desc: `!${tag}:${t('[value]')}`,
  252. documentation: 'is not equal to',
  253. },
  254. };
  255. }
  256. export function getValidOps(
  257. filterToken: TokenResult<Token.FILTER>
  258. ): readonly TermOperator[] {
  259. // If the token is invalid we want to use the possible expected types as our filter type
  260. const validTypes = filterToken.invalid?.expectedType ?? [filterToken.filter];
  261. // Determine any interchangeable filter types for our valid types
  262. const interchangeableTypes = validTypes.map(
  263. type => interchangeableFilterOperators[type] ?? []
  264. );
  265. // Combine all types
  266. const allValidTypes = [...new Set([...validTypes, ...interchangeableTypes.flat()])];
  267. // Find all valid operations
  268. const validOps = new Set<TermOperator>(
  269. allValidTypes.map(type => filterTypeConfig[type].validOps).flat()
  270. );
  271. return [...validOps];
  272. }
  273. export const shortcuts: Shortcut[] = [
  274. {
  275. text: 'Delete',
  276. shortcutType: ShortcutType.DELETE,
  277. hotkeys: {
  278. actual: 'ctrl+option+backspace',
  279. },
  280. icon: <IconDelete size="xs" color="gray300" />,
  281. canRunShortcut: token => {
  282. return token?.type === Token.FILTER;
  283. },
  284. },
  285. {
  286. text: 'Exclude',
  287. shortcutType: ShortcutType.NEGATE,
  288. hotkeys: {
  289. actual: 'ctrl+option+1',
  290. },
  291. icon: <IconExclamation size="xs" color="gray300" />,
  292. canRunShortcut: token => {
  293. return token?.type === Token.FILTER && !token.negated;
  294. },
  295. },
  296. {
  297. text: 'Include',
  298. shortcutType: ShortcutType.NEGATE,
  299. hotkeys: {
  300. actual: 'ctrl+option+1',
  301. },
  302. icon: <IconExclamation size="xs" color="gray300" />,
  303. canRunShortcut: token => {
  304. return token?.type === Token.FILTER && token.negated;
  305. },
  306. },
  307. {
  308. text: 'Previous',
  309. shortcutType: ShortcutType.PREVIOUS,
  310. hotkeys: {
  311. actual: 'ctrl+option+left',
  312. },
  313. icon: <IconArrow direction="left" size="xs" color="gray300" />,
  314. canRunShortcut: (token, count) => {
  315. return count > 1 || (count > 0 && token?.type !== Token.FILTER);
  316. },
  317. },
  318. {
  319. text: 'Next',
  320. shortcutType: ShortcutType.NEXT,
  321. hotkeys: {
  322. actual: 'ctrl+option+right',
  323. },
  324. icon: <IconArrow direction="right" size="xs" color="gray300" />,
  325. canRunShortcut: (token, count) => {
  326. return count > 1 || (count > 0 && token?.type !== Token.FILTER);
  327. },
  328. },
  329. ];
  330. const getItemTitle = (key: string, kind: FieldKind) => {
  331. if (kind === FieldKind.FUNCTION) {
  332. // Replace the function innards with ... for cleanliness
  333. return key.replace(/\(.*\)/g, '(...)');
  334. }
  335. return key;
  336. };
  337. /**
  338. * Groups tag keys based on the "." character in their key.
  339. * For example, "device.arch" and "device.name" will be grouped together as children of "device", a non-interactive parent.
  340. * The parent will become interactive if there exists a key "device".
  341. */
  342. export const getTagItemsFromKeys = (
  343. tagKeys: Readonly<Readonly<string>[]>,
  344. supportedTags: TagCollection,
  345. fieldDefinitionGetter: typeof getFieldDefinition = getFieldDefinition
  346. ) => {
  347. return tagKeys.reduce<SearchItem[]>((groups: SearchItem[], key: string) => {
  348. const keyWithColon = `${key}:`;
  349. const sections = key.split('.');
  350. const definition =
  351. supportedTags[key]?.kind === FieldKind.FUNCTION
  352. ? fieldDefinitionGetter(key.split('(')[0])
  353. : fieldDefinitionGetter(key);
  354. const kind = supportedTags[key]?.kind ?? definition?.kind ?? FieldKind.FIELD;
  355. const item: SearchItem = {
  356. value: keyWithColon,
  357. title: getItemTitle(key, kind),
  358. documentation: definition?.desc ?? '-',
  359. kind,
  360. deprecated: definition?.deprecated,
  361. featureFlag: definition?.featureFlag,
  362. };
  363. const lastGroup = groups.at(-1);
  364. const [title] = sections;
  365. if (kind !== FieldKind.FUNCTION && lastGroup) {
  366. if (lastGroup.children && lastGroup.title === title) {
  367. lastGroup.children.push(item);
  368. return groups;
  369. }
  370. if (lastGroup.title && lastGroup.title.split('.')[0] === title) {
  371. if (lastGroup.title === title) {
  372. return [
  373. ...groups.slice(0, -1),
  374. {
  375. title,
  376. value: lastGroup.value,
  377. documentation: lastGroup.documentation,
  378. kind: lastGroup.kind,
  379. children: [item],
  380. },
  381. ];
  382. }
  383. // Add a blank parent if the last group's full key is not the same as the title
  384. return [
  385. ...groups.slice(0, -1),
  386. {
  387. title,
  388. value: null,
  389. documentation: '-',
  390. kind: lastGroup.kind,
  391. children: [lastGroup, item],
  392. },
  393. ];
  394. }
  395. }
  396. groups.push(item);
  397. return groups;
  398. }, []);
  399. };
  400. /**
  401. * Sets an item as active within a search group array and returns new search groups without mutating.
  402. * the item is compared via value, so this function assumes that each value is unique.
  403. */
  404. export const getSearchGroupWithItemMarkedActive = (
  405. searchGroups: SearchGroup[],
  406. currentItem: SearchItem,
  407. active: boolean
  408. ): SearchGroup[] => {
  409. return searchGroups.map(group => ({
  410. ...group,
  411. children: group.children?.map(item => {
  412. if (item.value === currentItem.value && item.type === currentItem.type) {
  413. return {
  414. ...item,
  415. active,
  416. };
  417. }
  418. if (item.children && item.children.length > 0) {
  419. return {
  420. ...item,
  421. children: item.children.map(child => {
  422. if (child.value === currentItem.value && item.type === currentItem.type) {
  423. return {
  424. ...child,
  425. active,
  426. };
  427. }
  428. return child;
  429. }),
  430. };
  431. }
  432. return item;
  433. }),
  434. }));
  435. };
  436. /**
  437. * Filter tag keys based on the query and the key, description, and associated keywords of each tag.
  438. */
  439. export const filterKeysFromQuery = (
  440. tagKeys: string[],
  441. searchTerm: string,
  442. fieldDefinitionGetter: typeof getFieldDefinition = getFieldDefinition
  443. ): string[] =>
  444. tagKeys
  445. .flatMap(key => {
  446. const keyWithoutFunctionPart = key.replaceAll(/\(.*\)/g, '').toLocaleLowerCase();
  447. const definition = fieldDefinitionGetter(keyWithoutFunctionPart);
  448. const lowerCasedSearchTerm = searchTerm.toLocaleLowerCase();
  449. const combinedKeywords = [
  450. ...(definition?.desc ? [definition.desc] : []),
  451. ...(definition?.keywords ?? []),
  452. ]
  453. .join(' ')
  454. .toLocaleLowerCase();
  455. const matchedInKey = keyWithoutFunctionPart.includes(lowerCasedSearchTerm);
  456. const matchedInKeywords = combinedKeywords.includes(lowerCasedSearchTerm);
  457. if (!matchedInKey && !matchedInKeywords) {
  458. return [];
  459. }
  460. return [{matchedInKey, matchedInKeywords, key}];
  461. })
  462. .sort((a, b) => {
  463. // Sort by matched in key first, then by matched in keywords
  464. if (a.matchedInKey && !b.matchedInKey) {
  465. return -1;
  466. }
  467. if (b.matchedInKey && !a.matchedInKey) {
  468. return 1;
  469. }
  470. return a.key < b.key ? -1 : 1;
  471. })
  472. .map(({key}) => key);
  473. const DATE_SUGGESTED_VALUES = [
  474. {
  475. title: t('Last hour'),
  476. value: '-1h',
  477. desc: '-1h',
  478. type: ItemType.TAG_VALUE,
  479. },
  480. {
  481. title: t('Last 24 hours'),
  482. value: '-24h',
  483. desc: '-24h',
  484. type: ItemType.TAG_VALUE,
  485. },
  486. {
  487. title: t('Last 7 days'),
  488. value: '-7d',
  489. desc: '-7d',
  490. type: ItemType.TAG_VALUE,
  491. },
  492. {
  493. title: t('Last 14 days'),
  494. value: '-14d',
  495. desc: '-14d',
  496. type: ItemType.TAG_VALUE,
  497. },
  498. {
  499. title: t('Last 30 days'),
  500. value: '-30d',
  501. desc: '-30d',
  502. type: ItemType.TAG_VALUE,
  503. },
  504. {
  505. title: t('After a custom datetime'),
  506. value: '>',
  507. desc: '>YYYY-MM-DDThh:mm:ss',
  508. type: ItemType.TAG_VALUE_ISO_DATE,
  509. },
  510. {
  511. title: t('Before a custom datetime'),
  512. value: '<',
  513. desc: '<YYYY-MM-DDThh:mm:ss',
  514. type: ItemType.TAG_VALUE_ISO_DATE,
  515. },
  516. {
  517. title: t('At a custom datetime'),
  518. value: '=',
  519. desc: '=YYYY-MM-DDThh:mm:ss',
  520. type: ItemType.TAG_VALUE_ISO_DATE,
  521. },
  522. ] satisfies SearchItem[];
  523. export const getDateTagAutocompleteGroups = (tagName: string): AutocompleteGroup[] => {
  524. return [
  525. {
  526. searchItems: DATE_SUGGESTED_VALUES,
  527. recentSearchItems: [],
  528. tagName,
  529. type: ItemType.TAG_VALUE,
  530. },
  531. ];
  532. };
  533. export const getSearchConfigFromCustomPerformanceMetrics = (
  534. customPerformanceMetrics?: CustomMeasurementCollection
  535. ): Partial<SearchConfig> => {
  536. const searchConfigMap: Record<string, string[]> = {
  537. sizeKeys: [],
  538. durationKeys: [],
  539. percentageKeys: [],
  540. numericKeys: [],
  541. };
  542. if (customPerformanceMetrics) {
  543. Object.keys(customPerformanceMetrics).forEach(metricName => {
  544. const {fieldType} = customPerformanceMetrics[metricName];
  545. switch (fieldType) {
  546. case 'size':
  547. searchConfigMap.sizeKeys.push(metricName);
  548. break;
  549. case 'duration':
  550. searchConfigMap.durationKeys.push(metricName);
  551. break;
  552. case 'percentage':
  553. searchConfigMap.percentageKeys.push(metricName);
  554. break;
  555. default:
  556. searchConfigMap.numericKeys.push(metricName);
  557. }
  558. });
  559. }
  560. const searchConfig = {
  561. sizeKeys: new Set(searchConfigMap.sizeKeys),
  562. durationKeys: new Set(searchConfigMap.durationKeys),
  563. percentageKeys: new Set(searchConfigMap.percentageKeys),
  564. numericKeys: new Set(searchConfigMap.numericKeys),
  565. };
  566. return searchConfig;
  567. };
  568. /**
  569. * Gets an invalid group for when the usage of wildcards are not allowed and it is used in the search query.
  570. * When this group is set, a message with a link to the documentation is displayed to the user in the dropdown.
  571. */
  572. export function getAutoCompleteGroupForInvalidWildcard(searchText: string) {
  573. return [
  574. {
  575. searchItems: [
  576. {
  577. type: ItemType.INVALID_QUERY_WITH_WILDCARD,
  578. desc: searchText,
  579. callback: () =>
  580. window.open(
  581. 'https://docs.sentry.io/product/sentry-basics/search/searchable-properties/'
  582. ),
  583. },
  584. ],
  585. recentSearchItems: [],
  586. tagName: searchText,
  587. type: ItemType.INVALID_QUERY_WITH_WILDCARD,
  588. },
  589. ];
  590. }