utils.tsx 15 KB

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