utils.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461
  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 {FieldValueKind} from 'sentry/views/eventsV2/table/types';
  21. import {ItemType, SearchGroup, SearchItem, Shortcut, ShortcutType} from './types';
  22. import {Tag} from 'sentry/types';
  23. export function addSpace(query = '') {
  24. if (query.length !== 0 && query[query.length - 1] !== ' ') {
  25. return query + ' ';
  26. }
  27. return query;
  28. }
  29. export function removeSpace(query = '') {
  30. if (query[query.length - 1] === ' ') {
  31. return query.slice(0, query.length - 1);
  32. }
  33. return query;
  34. }
  35. /**
  36. * Given a query, and the current cursor position, return the string-delimiting
  37. * index of the search term designated by the cursor.
  38. */
  39. export function getLastTermIndex(query: string, cursor: number) {
  40. // TODO: work with quoted-terms
  41. const cursorOffset = query.slice(cursor).search(/\s|$/);
  42. return cursor + (cursorOffset === -1 ? 0 : cursorOffset);
  43. }
  44. /**
  45. * Returns an array of query terms, including incomplete terms
  46. *
  47. * e.g. ["is:unassigned", "browser:\"Chrome 33.0\"", "assigned"]
  48. */
  49. export function getQueryTerms(query: string, cursor: number) {
  50. return query.slice(0, cursor).match(/\S+:"[^"]*"?|\S+/g);
  51. }
  52. function getTitleForType(type: ItemType) {
  53. if (type === ItemType.TAG_VALUE) {
  54. return t('Values');
  55. }
  56. if (type === ItemType.RECENT_SEARCH) {
  57. return t('Recent Searches');
  58. }
  59. if (type === ItemType.DEFAULT) {
  60. return t('Common Search Terms');
  61. }
  62. if (type === ItemType.TAG_OPERATOR) {
  63. return t('Operator Helpers');
  64. }
  65. if (type === ItemType.PROPERTY) {
  66. return t('Properties');
  67. }
  68. return t('Keys');
  69. }
  70. function getIconForTypeAndTag(type: ItemType, tagName: string) {
  71. if (type === ItemType.RECENT_SEARCH) {
  72. return <IconClock size="xs" />;
  73. }
  74. if (type === ItemType.DEFAULT) {
  75. return <IconStar size="xs" />;
  76. }
  77. // Change based on tagName and default to "icon-tag"
  78. switch (tagName) {
  79. case 'is':
  80. return <IconToggle size="xs" />;
  81. case 'assigned':
  82. case 'bookmarks':
  83. return <IconUser size="xs" />;
  84. case 'firstSeen':
  85. case 'lastSeen':
  86. case 'event.timestamp':
  87. return <IconClock size="xs" />;
  88. default:
  89. return <IconTag size="xs" />;
  90. }
  91. }
  92. const filterSearchItems = (
  93. searchItems: SearchItem[],
  94. recentSearchItems?: SearchItem[],
  95. maxSearchItems?: number,
  96. queryCharsLeft?: number
  97. ) => {
  98. if (maxSearchItems && maxSearchItems > 0) {
  99. searchItems = searchItems.filter(
  100. (value: SearchItem, index: number) =>
  101. index < maxSearchItems || value.ignoreMaxSearchItems
  102. );
  103. }
  104. if (queryCharsLeft || queryCharsLeft === 0) {
  105. searchItems = searchItems.flatMap(item => {
  106. if (!item.children) {
  107. if (!item.value || item.value.length <= queryCharsLeft) {
  108. return [item];
  109. }
  110. return [];
  111. }
  112. const newItem = {
  113. ...item,
  114. children: item.children.filter(
  115. child => !child.value || child.value.length <= queryCharsLeft
  116. ),
  117. };
  118. if (newItem.children.length === 0) {
  119. return [];
  120. }
  121. return [newItem];
  122. });
  123. searchItems = searchItems.filter(
  124. (value: SearchItem) => !value.value || value.value.length <= queryCharsLeft
  125. );
  126. if (recentSearchItems) {
  127. recentSearchItems = recentSearchItems.filter(
  128. (value: SearchItem) => !value.value || value.value.length <= queryCharsLeft
  129. );
  130. }
  131. }
  132. return {searchItems, recentSearchItems};
  133. };
  134. export function createSearchGroups(
  135. searchItems: SearchItem[],
  136. recentSearchItems: SearchItem[] | undefined,
  137. tagName: string,
  138. type: ItemType,
  139. maxSearchItems?: number,
  140. queryCharsLeft?: number,
  141. isDefaultState?: boolean
  142. ) {
  143. const activeSearchItem = 0;
  144. const {searchItems: filteredSearchItems, recentSearchItems: filteredRecentSearchItems} =
  145. filterSearchItems(searchItems, recentSearchItems, maxSearchItems, queryCharsLeft);
  146. const searchGroup: SearchGroup = {
  147. title: getTitleForType(type),
  148. type: type === ItemType.INVALID_TAG ? type : 'header',
  149. icon: getIconForTypeAndTag(type, tagName),
  150. children: [...filteredSearchItems],
  151. };
  152. const recentSearchGroup: SearchGroup | undefined =
  153. filteredRecentSearchItems && filteredRecentSearchItems.length > 0
  154. ? {
  155. title: t('Recent Searches'),
  156. type: 'header',
  157. icon: <IconClock size="xs" />,
  158. children: [...filteredRecentSearchItems],
  159. }
  160. : undefined;
  161. if (searchGroup.children && !!searchGroup.children.length) {
  162. searchGroup.children[activeSearchItem] = {
  163. ...searchGroup.children[activeSearchItem],
  164. };
  165. }
  166. const flatSearchItems = filteredSearchItems.flatMap(item => {
  167. if (item.children) {
  168. if (!item.value) {
  169. return [...item.children];
  170. }
  171. return [item, ...item.children];
  172. }
  173. return [item];
  174. });
  175. if (isDefaultState) {
  176. // Recent searches first in default state.
  177. return {
  178. searchGroups: [...(recentSearchGroup ? [recentSearchGroup] : []), searchGroup],
  179. flatSearchItems: [
  180. ...(recentSearchItems ? recentSearchItems : []),
  181. ...flatSearchItems,
  182. ],
  183. activeSearchItem: -1,
  184. };
  185. }
  186. return {
  187. searchGroups: [searchGroup, ...(recentSearchGroup ? [recentSearchGroup] : [])],
  188. flatSearchItems: [
  189. ...flatSearchItems,
  190. ...(recentSearchItems ? recentSearchItems : []),
  191. ],
  192. activeSearchItem: -1,
  193. };
  194. }
  195. export function generateOperatorEntryMap(tag: string) {
  196. return {
  197. [TermOperator.Default]: {
  198. type: ItemType.TAG_OPERATOR,
  199. value: ':',
  200. desc: `${tag}:${t('[value]')}`,
  201. documentation: 'is equal to',
  202. },
  203. [TermOperator.GreaterThanEqual]: {
  204. type: ItemType.TAG_OPERATOR,
  205. value: ':>=',
  206. desc: `${tag}:${t('>=[value]')}`,
  207. documentation: 'is greater than or equal to',
  208. },
  209. [TermOperator.LessThanEqual]: {
  210. type: ItemType.TAG_OPERATOR,
  211. value: ':<=',
  212. desc: `${tag}:${t('<=[value]')}`,
  213. documentation: 'is less than or equal to',
  214. },
  215. [TermOperator.GreaterThan]: {
  216. type: ItemType.TAG_OPERATOR,
  217. value: ':>',
  218. desc: `${tag}:${t('>[value]')}`,
  219. documentation: 'is greater than',
  220. },
  221. [TermOperator.LessThan]: {
  222. type: ItemType.TAG_OPERATOR,
  223. value: ':<',
  224. desc: `${tag}:${t('<[value]')}`,
  225. documentation: 'is less than',
  226. },
  227. [TermOperator.Equal]: {
  228. type: ItemType.TAG_OPERATOR,
  229. value: ':=',
  230. desc: `${tag}:${t('=[value]')}`,
  231. documentation: 'is equal to',
  232. },
  233. [TermOperator.NotEqual]: {
  234. type: ItemType.TAG_OPERATOR,
  235. value: '!:',
  236. desc: `!${tag}:${t('[value]')}`,
  237. documentation: 'is not equal to',
  238. },
  239. };
  240. }
  241. export function getValidOps(
  242. filterToken: TokenResult<Token.Filter>
  243. ): readonly TermOperator[] {
  244. // If the token is invalid we want to use the possible expected types as our filter type
  245. const validTypes = filterToken.invalid?.expectedType ?? [filterToken.filter];
  246. // Determine any interchangeable filter types for our valid types
  247. const interchangeableTypes = validTypes.map(
  248. type => interchangeableFilterOperators[type] ?? []
  249. );
  250. // Combine all types
  251. const allValidTypes = [...new Set([...validTypes, ...interchangeableTypes.flat()])];
  252. // Find all valid operations
  253. const validOps = new Set<TermOperator>(
  254. allValidTypes.map(type => filterTypeConfig[type].validOps).flat()
  255. );
  256. return [...validOps];
  257. }
  258. export const shortcuts: Shortcut[] = [
  259. {
  260. text: 'Delete',
  261. shortcutType: ShortcutType.Delete,
  262. hotkeys: {
  263. actual: 'option+backspace',
  264. },
  265. icon: <IconDelete size="xs" color="gray300" />,
  266. canRunShortcut: token => {
  267. return token?.type === Token.Filter;
  268. },
  269. },
  270. {
  271. text: 'Exclude',
  272. shortcutType: ShortcutType.Negate,
  273. hotkeys: {
  274. actual: 'option+1',
  275. },
  276. icon: <IconExclamation size="xs" color="gray300" />,
  277. canRunShortcut: token => {
  278. return token?.type === Token.Filter && !token.negated;
  279. },
  280. },
  281. {
  282. text: 'Include',
  283. shortcutType: ShortcutType.Negate,
  284. hotkeys: {
  285. actual: 'option+1',
  286. },
  287. icon: <IconExclamation size="xs" color="gray300" />,
  288. canRunShortcut: token => {
  289. return token?.type === Token.Filter && token.negated;
  290. },
  291. },
  292. {
  293. text: 'Previous',
  294. shortcutType: ShortcutType.Previous,
  295. hotkeys: {
  296. actual: 'option+left',
  297. },
  298. icon: <IconArrow direction="left" size="xs" color="gray300" />,
  299. canRunShortcut: (token, count) => {
  300. return count > 1 || (count > 0 && token?.type !== Token.Filter);
  301. },
  302. },
  303. {
  304. text: 'Next',
  305. shortcutType: ShortcutType.Next,
  306. hotkeys: {
  307. actual: 'option+right',
  308. },
  309. icon: <IconArrow direction="right" size="xs" color="gray300" />,
  310. canRunShortcut: (token, count) => {
  311. return count > 1 || (count > 0 && token?.type !== Token.Filter);
  312. },
  313. },
  314. ];
  315. /**
  316. * Groups tag keys based on the "." character in their key.
  317. * For example, "device.arch" and "device.name" will be grouped together as children of "device", a non-interactive parent.
  318. * The parent will become interactive if there exists a key "device".
  319. */
  320. export const getTagItemsFromKeys = (
  321. tagKeys: string[],
  322. supportedTags: {
  323. [key: string]: Tag;
  324. },
  325. getFieldDoc?: (key: string) => React.ReactNode
  326. ) => {
  327. return [...tagKeys]
  328. .sort((a, b) => a.localeCompare(b))
  329. .reduce((groups, key) => {
  330. const keyWithColon = `${key}:`;
  331. const sections = key.split('.');
  332. const kind = supportedTags[key]?.kind;
  333. const documentation = getFieldDoc?.(key) || '-';
  334. const item: SearchItem = {
  335. value: keyWithColon,
  336. title: key,
  337. documentation,
  338. kind,
  339. };
  340. const lastGroup = groups.at(-1);
  341. const [title] = sections;
  342. if (kind !== FieldValueKind.FUNCTION && lastGroup) {
  343. if (lastGroup.children && lastGroup.title === title) {
  344. lastGroup.children.push(item);
  345. return groups;
  346. }
  347. if (lastGroup.title && lastGroup.title.split('.')[0] === title) {
  348. if (lastGroup.title === title) {
  349. return [
  350. ...groups.slice(0, -1),
  351. {
  352. title,
  353. value: lastGroup.value,
  354. documentation: lastGroup.documentation,
  355. kind: lastGroup.kind,
  356. children: [item],
  357. },
  358. ];
  359. }
  360. // Add a blank parent if the last group's full key is not the same as the title
  361. return [
  362. ...groups.slice(0, -1),
  363. {
  364. title,
  365. value: null,
  366. documentation: '-',
  367. kind: lastGroup.kind,
  368. children: [lastGroup, item],
  369. },
  370. ];
  371. }
  372. }
  373. return [...groups, item];
  374. }, [] as SearchItem[]);
  375. };
  376. /**
  377. * Sets an item as active within a search group array and returns new search groups without mutating.
  378. * the item is compared via value, so this function assumes that each value is unique.
  379. */
  380. export const getSearchGroupWithItemMarkedActive = (
  381. searchGroups: SearchGroup[],
  382. currentItem: SearchItem,
  383. active: boolean
  384. ) => {
  385. return searchGroups.map(group => ({
  386. ...group,
  387. children: group.children?.map(item => {
  388. if (item.value === currentItem.value) {
  389. return {
  390. ...item,
  391. active,
  392. };
  393. }
  394. if (item.children && item.children.length > 0) {
  395. return {
  396. ...item,
  397. children: item.children.map(child => {
  398. if (child.value === currentItem.value) {
  399. return {
  400. ...child,
  401. active,
  402. };
  403. }
  404. return child;
  405. }),
  406. };
  407. }
  408. return item;
  409. }),
  410. }));
  411. };