utils.tsx 16 KB

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