utils.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636
  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. fieldDefinitionGetter: typeof getFieldDefinition = getFieldDefinition
  152. ) {
  153. const fieldDefinition = fieldDefinitionGetter(tagName);
  154. const activeSearchItem = 0;
  155. const {searchItems: filteredSearchItems, recentSearchItems: filteredRecentSearchItems} =
  156. filterSearchItems(searchItems, recentSearchItems, maxSearchItems, queryCharsLeft);
  157. const searchGroup: SearchGroup = {
  158. title: getTitleForType(type),
  159. type: type === ItemType.INVALID_TAG ? type : 'header',
  160. icon: getIconForTypeAndTag(type, tagName),
  161. children: [...filteredSearchItems],
  162. };
  163. const recentSearchGroup: SearchGroup | undefined =
  164. filteredRecentSearchItems && filteredRecentSearchItems.length > 0
  165. ? {
  166. title: t('Recent Searches'),
  167. type: 'header',
  168. icon: <IconClock size="xs" />,
  169. children: [...filteredRecentSearchItems],
  170. }
  171. : undefined;
  172. if (searchGroup.children && !!searchGroup.children.length) {
  173. searchGroup.children[activeSearchItem] = {
  174. ...searchGroup.children[activeSearchItem],
  175. };
  176. }
  177. const flatSearchItems = filteredSearchItems.flatMap(item => {
  178. if (item.children) {
  179. if (!item.value) {
  180. return [...item.children];
  181. }
  182. return [item, ...item.children];
  183. }
  184. return [item];
  185. });
  186. if (fieldDefinition?.valueType === FieldValueType.DATE) {
  187. if (type === ItemType.TAG_OPERATOR) {
  188. return {
  189. searchGroups: [],
  190. flatSearchItems: [],
  191. activeSearchItem: -1,
  192. };
  193. }
  194. }
  195. if (isDefaultState) {
  196. // Recent searches first in default state.
  197. return {
  198. searchGroups: [...(recentSearchGroup ? [recentSearchGroup] : []), searchGroup],
  199. flatSearchItems: [
  200. ...(recentSearchItems ? recentSearchItems : []),
  201. ...flatSearchItems,
  202. ],
  203. activeSearchItem: -1,
  204. };
  205. }
  206. return {
  207. searchGroups: [searchGroup, ...(recentSearchGroup ? [recentSearchGroup] : [])],
  208. flatSearchItems: [
  209. ...flatSearchItems,
  210. ...(recentSearchItems ? recentSearchItems : []),
  211. ],
  212. activeSearchItem: -1,
  213. };
  214. }
  215. export function generateOperatorEntryMap(tag: string) {
  216. return {
  217. [TermOperator.Default]: {
  218. type: ItemType.TAG_OPERATOR,
  219. value: ':',
  220. desc: `${tag}:${t('[value]')}`,
  221. documentation: 'is equal to',
  222. },
  223. [TermOperator.GreaterThanEqual]: {
  224. type: ItemType.TAG_OPERATOR,
  225. value: ':>=',
  226. desc: `${tag}:${t('>=[value]')}`,
  227. documentation: 'is greater than or equal to',
  228. },
  229. [TermOperator.LessThanEqual]: {
  230. type: ItemType.TAG_OPERATOR,
  231. value: ':<=',
  232. desc: `${tag}:${t('<=[value]')}`,
  233. documentation: 'is less than or equal to',
  234. },
  235. [TermOperator.GreaterThan]: {
  236. type: ItemType.TAG_OPERATOR,
  237. value: ':>',
  238. desc: `${tag}:${t('>[value]')}`,
  239. documentation: 'is greater than',
  240. },
  241. [TermOperator.LessThan]: {
  242. type: ItemType.TAG_OPERATOR,
  243. value: ':<',
  244. desc: `${tag}:${t('<[value]')}`,
  245. documentation: 'is less than',
  246. },
  247. [TermOperator.Equal]: {
  248. type: ItemType.TAG_OPERATOR,
  249. value: ':=',
  250. desc: `${tag}:${t('=[value]')}`,
  251. documentation: 'is equal to',
  252. },
  253. [TermOperator.NotEqual]: {
  254. type: ItemType.TAG_OPERATOR,
  255. value: '!:',
  256. desc: `!${tag}:${t('[value]')}`,
  257. documentation: 'is not equal to',
  258. },
  259. };
  260. }
  261. export function getValidOps(
  262. filterToken: TokenResult<Token.Filter>
  263. ): readonly TermOperator[] {
  264. // If the token is invalid we want to use the possible expected types as our filter type
  265. const validTypes = filterToken.invalid?.expectedType ?? [filterToken.filter];
  266. // Determine any interchangeable filter types for our valid types
  267. const interchangeableTypes = validTypes.map(
  268. type => interchangeableFilterOperators[type] ?? []
  269. );
  270. // Combine all types
  271. const allValidTypes = [...new Set([...validTypes, ...interchangeableTypes.flat()])];
  272. // Find all valid operations
  273. const validOps = new Set<TermOperator>(
  274. allValidTypes.map(type => filterTypeConfig[type].validOps).flat()
  275. );
  276. return [...validOps];
  277. }
  278. export const shortcuts: Shortcut[] = [
  279. {
  280. text: 'Delete',
  281. shortcutType: ShortcutType.Delete,
  282. hotkeys: {
  283. actual: 'ctrl+option+backspace',
  284. },
  285. icon: <IconDelete size="xs" color="gray300" />,
  286. canRunShortcut: token => {
  287. return token?.type === Token.Filter;
  288. },
  289. },
  290. {
  291. text: 'Exclude',
  292. shortcutType: ShortcutType.Negate,
  293. hotkeys: {
  294. actual: 'ctrl+option+1',
  295. },
  296. icon: <IconExclamation size="xs" color="gray300" />,
  297. canRunShortcut: token => {
  298. return token?.type === Token.Filter && !token.negated;
  299. },
  300. },
  301. {
  302. text: 'Include',
  303. shortcutType: ShortcutType.Negate,
  304. hotkeys: {
  305. actual: 'ctrl+option+1',
  306. },
  307. icon: <IconExclamation size="xs" color="gray300" />,
  308. canRunShortcut: token => {
  309. return token?.type === Token.Filter && token.negated;
  310. },
  311. },
  312. {
  313. text: 'Previous',
  314. shortcutType: ShortcutType.Previous,
  315. hotkeys: {
  316. actual: 'ctrl+option+left',
  317. },
  318. icon: <IconArrow direction="left" size="xs" color="gray300" />,
  319. canRunShortcut: (token, count) => {
  320. return count > 1 || (count > 0 && token?.type !== Token.Filter);
  321. },
  322. },
  323. {
  324. text: 'Next',
  325. shortcutType: ShortcutType.Next,
  326. hotkeys: {
  327. actual: 'ctrl+option+right',
  328. },
  329. icon: <IconArrow direction="right" size="xs" color="gray300" />,
  330. canRunShortcut: (token, count) => {
  331. return count > 1 || (count > 0 && token?.type !== Token.Filter);
  332. },
  333. },
  334. ];
  335. const getItemTitle = (key: string, kind: FieldKind) => {
  336. if (kind === FieldKind.FUNCTION) {
  337. // Replace the function innards with ... for cleanliness
  338. return key.replace(/\(.*\)/g, '(...)');
  339. }
  340. return key;
  341. };
  342. /**
  343. * Groups tag keys based on the "." character in their key.
  344. * For example, "device.arch" and "device.name" will be grouped together as children of "device", a non-interactive parent.
  345. * The parent will become interactive if there exists a key "device".
  346. */
  347. export const getTagItemsFromKeys = (
  348. tagKeys: string[],
  349. supportedTags: TagCollection,
  350. fieldDefinitionGetter: typeof getFieldDefinition = getFieldDefinition
  351. ) => {
  352. return [...tagKeys].reduce<SearchItem[]>((groups, key) => {
  353. const keyWithColon = `${key}:`;
  354. const sections = key.split('.');
  355. const definition =
  356. supportedTags[key]?.kind === FieldKind.FUNCTION
  357. ? fieldDefinitionGetter(key.split('(')[0])
  358. : fieldDefinitionGetter(key);
  359. const kind = supportedTags[key]?.kind ?? definition?.kind ?? FieldKind.FIELD;
  360. const item: SearchItem = {
  361. value: keyWithColon,
  362. title: getItemTitle(key, kind),
  363. documentation: definition?.desc ?? '-',
  364. kind,
  365. deprecated: definition?.deprecated,
  366. featureFlag: definition?.featureFlag,
  367. };
  368. const lastGroup = groups.at(-1);
  369. const [title] = sections;
  370. if (kind !== FieldKind.FUNCTION && lastGroup) {
  371. if (lastGroup.children && lastGroup.title === title) {
  372. lastGroup.children.push(item);
  373. return groups;
  374. }
  375. if (lastGroup.title && lastGroup.title.split('.')[0] === title) {
  376. if (lastGroup.title === title) {
  377. return [
  378. ...groups.slice(0, -1),
  379. {
  380. title,
  381. value: lastGroup.value,
  382. documentation: lastGroup.documentation,
  383. kind: lastGroup.kind,
  384. children: [item],
  385. },
  386. ];
  387. }
  388. // Add a blank parent if the last group's full key is not the same as the title
  389. return [
  390. ...groups.slice(0, -1),
  391. {
  392. title,
  393. value: null,
  394. documentation: '-',
  395. kind: lastGroup.kind,
  396. children: [lastGroup, item],
  397. },
  398. ];
  399. }
  400. }
  401. return [...groups, item];
  402. }, []);
  403. };
  404. /**
  405. * Sets an item as active within a search group array and returns new search groups without mutating.
  406. * the item is compared via value, so this function assumes that each value is unique.
  407. */
  408. export const getSearchGroupWithItemMarkedActive = (
  409. searchGroups: SearchGroup[],
  410. currentItem: SearchItem,
  411. active: boolean
  412. ) => {
  413. return searchGroups.map(group => ({
  414. ...group,
  415. children: group.children?.map(item => {
  416. if (item.value === currentItem.value) {
  417. return {
  418. ...item,
  419. active,
  420. };
  421. }
  422. if (item.children && item.children.length > 0) {
  423. return {
  424. ...item,
  425. children: item.children.map(child => {
  426. if (child.value === currentItem.value) {
  427. return {
  428. ...child,
  429. active,
  430. };
  431. }
  432. return child;
  433. }),
  434. };
  435. }
  436. return item;
  437. }),
  438. }));
  439. };
  440. /**
  441. * Filter tag keys based on the query and the key, description, and associated keywords of each tag.
  442. */
  443. export const filterKeysFromQuery = (
  444. tagKeys: string[],
  445. searchTerm: string,
  446. fieldDefinitionGetter: typeof getFieldDefinition = getFieldDefinition
  447. ): string[] =>
  448. tagKeys
  449. .flatMap(key => {
  450. const keyWithoutFunctionPart = key.replaceAll(/\(.*\)/g, '');
  451. const definition = fieldDefinitionGetter(keyWithoutFunctionPart);
  452. const lowerCasedSearchTerm = searchTerm.toLocaleLowerCase();
  453. const combinedKeywords = [
  454. ...(definition?.desc ? [definition.desc] : []),
  455. ...(definition?.keywords ?? []),
  456. ]
  457. .join(' ')
  458. .toLocaleLowerCase();
  459. const matchedInKey = keyWithoutFunctionPart.includes(lowerCasedSearchTerm);
  460. const matchedInKeywords = combinedKeywords.includes(lowerCasedSearchTerm);
  461. if (!matchedInKey && !matchedInKeywords) {
  462. return [];
  463. }
  464. return [{matchedInKey, matchedInKeywords, key}];
  465. })
  466. .sort((a, b) => {
  467. // Sort by matched in key first, then by matched in keywords
  468. if (a.matchedInKey && !b.matchedInKey) {
  469. return -1;
  470. }
  471. if (b.matchedInKey && !a.matchedInKey) {
  472. return 1;
  473. }
  474. return a.key < b.key ? -1 : 1;
  475. })
  476. .map(({key}) => key);
  477. const DATE_SUGGESTED_VALUES = [
  478. {
  479. title: t('Last hour'),
  480. value: '-1h',
  481. desc: '-1h',
  482. type: ItemType.TAG_VALUE,
  483. },
  484. {
  485. title: t('Last 24 hours'),
  486. value: '-24h',
  487. desc: '-24h',
  488. type: ItemType.TAG_VALUE,
  489. },
  490. {
  491. title: t('Last 7 days'),
  492. value: '-7d',
  493. desc: '-7d',
  494. type: ItemType.TAG_VALUE,
  495. },
  496. {
  497. title: t('Last 14 days'),
  498. value: '-14d',
  499. desc: '-14d',
  500. type: ItemType.TAG_VALUE,
  501. },
  502. {
  503. title: t('Last 30 days'),
  504. value: '-30d',
  505. desc: '-30d',
  506. type: ItemType.TAG_VALUE,
  507. },
  508. {
  509. title: t('After a custom datetime'),
  510. value: '>',
  511. desc: '>YYYY-MM-DDThh:mm:ss',
  512. type: ItemType.TAG_VALUE_ISO_DATE,
  513. },
  514. {
  515. title: t('Before a custom datetime'),
  516. value: '<',
  517. desc: '<YYYY-MM-DDThh:mm:ss',
  518. type: ItemType.TAG_VALUE_ISO_DATE,
  519. },
  520. {
  521. title: t('At a custom datetime'),
  522. value: '=',
  523. desc: '=YYYY-MM-DDThh:mm:ss',
  524. type: ItemType.TAG_VALUE_ISO_DATE,
  525. },
  526. ];
  527. export const getDateTagAutocompleteGroups = (tagName: string): AutocompleteGroup[] => {
  528. return [
  529. {
  530. searchItems: DATE_SUGGESTED_VALUES,
  531. recentSearchItems: [],
  532. tagName,
  533. type: ItemType.TAG_VALUE,
  534. },
  535. ];
  536. };
  537. export const getSearchConfigFromCustomPerformanceMetrics = (
  538. customPerformanceMetrics?: CustomMeasurementCollection
  539. ): Partial<SearchConfig> => {
  540. const searchConfigMap: Record<string, string[]> = {
  541. sizeKeys: [],
  542. durationKeys: [],
  543. percentageKeys: [],
  544. numericKeys: [],
  545. };
  546. if (customPerformanceMetrics) {
  547. Object.keys(customPerformanceMetrics).forEach(metricName => {
  548. const {fieldType} = customPerformanceMetrics[metricName];
  549. switch (fieldType) {
  550. case 'size':
  551. searchConfigMap.sizeKeys.push(metricName);
  552. break;
  553. case 'duration':
  554. searchConfigMap.durationKeys.push(metricName);
  555. break;
  556. case 'percentage':
  557. searchConfigMap.percentageKeys.push(metricName);
  558. break;
  559. default:
  560. searchConfigMap.numericKeys.push(metricName);
  561. }
  562. });
  563. }
  564. const searchConfig = {
  565. sizeKeys: new Set(searchConfigMap.sizeKeys),
  566. durationKeys: new Set(searchConfigMap.durationKeys),
  567. percentageKeys: new Set(searchConfigMap.percentageKeys),
  568. numericKeys: new Set(searchConfigMap.numericKeys),
  569. };
  570. return searchConfig;
  571. };