utils.tsx 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. import {
  2. filterTypeConfig,
  3. interchangeableFilterOperators,
  4. TermOperator,
  5. Token,
  6. TokenResult,
  7. } from 'sentry/components/searchSyntax/parser';
  8. import {
  9. IconArrow,
  10. IconClock,
  11. IconDelete,
  12. IconExclamation,
  13. IconStar,
  14. IconTag,
  15. IconToggle,
  16. IconUser,
  17. } from 'sentry/icons';
  18. import {t} from 'sentry/locale';
  19. import {ItemType, SearchGroup, SearchItem, Shortcut, ShortcutType} from './types';
  20. export function addSpace(query = '') {
  21. if (query.length !== 0 && query[query.length - 1] !== ' ') {
  22. return query + ' ';
  23. }
  24. return query;
  25. }
  26. export function removeSpace(query = '') {
  27. if (query[query.length - 1] === ' ') {
  28. return query.slice(0, query.length - 1);
  29. }
  30. return query;
  31. }
  32. /**
  33. * Given a query, and the current cursor position, return the string-delimiting
  34. * index of the search term designated by the cursor.
  35. */
  36. export function getLastTermIndex(query: string, cursor: number) {
  37. // TODO: work with quoted-terms
  38. const cursorOffset = query.slice(cursor).search(/\s|$/);
  39. return cursor + (cursorOffset === -1 ? 0 : cursorOffset);
  40. }
  41. /**
  42. * Returns an array of query terms, including incomplete terms
  43. *
  44. * e.g. ["is:unassigned", "browser:\"Chrome 33.0\"", "assigned"]
  45. */
  46. export function getQueryTerms(query: string, cursor: number) {
  47. return query.slice(0, cursor).match(/\S+:"[^"]*"?|\S+/g);
  48. }
  49. function getTitleForType(type: ItemType) {
  50. if (type === ItemType.TAG_VALUE) {
  51. return t('Tag Values');
  52. }
  53. if (type === ItemType.RECENT_SEARCH) {
  54. return t('Recent Searches');
  55. }
  56. if (type === ItemType.DEFAULT) {
  57. return t('Common Search Terms');
  58. }
  59. if (type === ItemType.TAG_OPERATOR) {
  60. return t('Operator Helpers');
  61. }
  62. if (type === ItemType.PROPERTY) {
  63. return t('Properties');
  64. }
  65. return t('Tags');
  66. }
  67. function getIconForTypeAndTag(type: ItemType, tagName: string) {
  68. if (type === ItemType.RECENT_SEARCH) {
  69. return <IconClock size="xs" />;
  70. }
  71. if (type === ItemType.DEFAULT) {
  72. return <IconStar size="xs" />;
  73. }
  74. // Change based on tagName and default to "icon-tag"
  75. switch (tagName) {
  76. case 'is':
  77. return <IconToggle size="xs" />;
  78. case 'assigned':
  79. case 'bookmarks':
  80. return <IconUser size="xs" />;
  81. case 'firstSeen':
  82. case 'lastSeen':
  83. case 'event.timestamp':
  84. return <IconClock size="xs" />;
  85. default:
  86. return <IconTag size="xs" />;
  87. }
  88. }
  89. export function createSearchGroups(
  90. searchItems: SearchItem[],
  91. recentSearchItems: SearchItem[] | undefined,
  92. tagName: string,
  93. type: ItemType,
  94. maxSearchItems: number | undefined,
  95. queryCharsLeft?: number
  96. ) {
  97. const activeSearchItem = 0;
  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.filter(
  106. (value: SearchItem) =>
  107. typeof value.value !== 'undefined' && value.value.length <= queryCharsLeft
  108. );
  109. if (recentSearchItems) {
  110. recentSearchItems = recentSearchItems.filter(
  111. (value: SearchItem) =>
  112. typeof value.value !== 'undefined' && value.value.length <= queryCharsLeft
  113. );
  114. }
  115. }
  116. const searchGroup: SearchGroup = {
  117. title: getTitleForType(type),
  118. type: type === ItemType.INVALID_TAG ? type : 'header',
  119. icon: getIconForTypeAndTag(type, tagName),
  120. children: [...searchItems],
  121. };
  122. const recentSearchGroup: SearchGroup | undefined =
  123. recentSearchItems && recentSearchItems.length > 0
  124. ? {
  125. title: t('Recent Searches'),
  126. type: 'header',
  127. icon: <IconClock size="xs" />,
  128. children: [...recentSearchItems],
  129. }
  130. : undefined;
  131. if (searchGroup.children && !!searchGroup.children.length) {
  132. searchGroup.children[activeSearchItem] = {
  133. ...searchGroup.children[activeSearchItem],
  134. };
  135. }
  136. return {
  137. searchGroups: [searchGroup, ...(recentSearchGroup ? [recentSearchGroup] : [])],
  138. flatSearchItems: [...searchItems, ...(recentSearchItems ? recentSearchItems : [])],
  139. activeSearchItem: -1,
  140. };
  141. }
  142. /**
  143. * Items is a list of dropdown groups that have a `children` field. Only the
  144. * `children` are selectable, so we need to find which child is selected given
  145. * an index that is in range of the sum of all `children` lengths
  146. *
  147. * @return Returns a tuple of [groupIndex, childrenIndex]
  148. */
  149. export function filterSearchGroupsByIndex(items: SearchGroup[], index: number) {
  150. let _index = index;
  151. let foundSearchItem: [number?, number?] = [undefined, undefined];
  152. items.find(({children}, i) => {
  153. if (!children || !children.length) {
  154. return false;
  155. }
  156. if (_index < children.length) {
  157. foundSearchItem = [i, _index];
  158. return true;
  159. }
  160. _index -= children.length;
  161. return false;
  162. });
  163. return foundSearchItem;
  164. }
  165. export function generateOperatorEntryMap(tag: string) {
  166. return {
  167. [TermOperator.Default]: {
  168. type: ItemType.TAG_OPERATOR,
  169. value: ':',
  170. desc: `${tag}:${t('[value]')}`,
  171. documentation: 'is equal to',
  172. },
  173. [TermOperator.GreaterThanEqual]: {
  174. type: ItemType.TAG_OPERATOR,
  175. value: ':>=',
  176. desc: `${tag}:${t('>=[value]')}`,
  177. documentation: 'is greater than or equal to',
  178. },
  179. [TermOperator.LessThanEqual]: {
  180. type: ItemType.TAG_OPERATOR,
  181. value: ':<=',
  182. desc: `${tag}:${t('<=[value]')}`,
  183. documentation: 'is less than or equal to',
  184. },
  185. [TermOperator.GreaterThan]: {
  186. type: ItemType.TAG_OPERATOR,
  187. value: ':>',
  188. desc: `${tag}:${t('>[value]')}`,
  189. documentation: 'is greater than',
  190. },
  191. [TermOperator.LessThan]: {
  192. type: ItemType.TAG_OPERATOR,
  193. value: ':<',
  194. desc: `${tag}:${t('<[value]')}`,
  195. documentation: 'is less than',
  196. },
  197. [TermOperator.Equal]: {
  198. type: ItemType.TAG_OPERATOR,
  199. value: ':=',
  200. desc: `${tag}:${t('=[value]')}`,
  201. documentation: 'is equal to',
  202. },
  203. [TermOperator.NotEqual]: {
  204. type: ItemType.TAG_OPERATOR,
  205. value: '!:',
  206. desc: `!${tag}:${t('[value]')}`,
  207. documentation: 'is not equal to',
  208. },
  209. };
  210. }
  211. export function getValidOps(
  212. filterToken: TokenResult<Token.Filter>
  213. ): readonly TermOperator[] {
  214. // If the token is invalid we want to use the possible expected types as our filter type
  215. const validTypes = filterToken.invalid?.expectedType ?? [filterToken.filter];
  216. // Determine any interchangeable filter types for our valid types
  217. const interchangeableTypes = validTypes.map(
  218. type => interchangeableFilterOperators[type] ?? []
  219. );
  220. // Combine all types
  221. const allValidTypes = [...new Set([...validTypes, ...interchangeableTypes.flat()])];
  222. // Find all valid operations
  223. const validOps = new Set<TermOperator>(
  224. allValidTypes.map(type => filterTypeConfig[type].validOps).flat()
  225. );
  226. return [...validOps];
  227. }
  228. export const shortcuts: Shortcut[] = [
  229. {
  230. text: 'Delete',
  231. shortcutType: ShortcutType.Delete,
  232. hotkeys: {
  233. actual: 'option+backspace',
  234. },
  235. icon: <IconDelete size="xs" color="gray300" />,
  236. canRunShortcut: token => {
  237. return token?.type === Token.Filter;
  238. },
  239. },
  240. {
  241. text: 'Exclude',
  242. shortcutType: ShortcutType.Negate,
  243. hotkeys: {
  244. actual: 'option+1',
  245. },
  246. icon: <IconExclamation size="xs" color="gray300" />,
  247. canRunShortcut: token => {
  248. return token?.type === Token.Filter && !token.negated;
  249. },
  250. },
  251. {
  252. text: 'Include',
  253. shortcutType: ShortcutType.Negate,
  254. hotkeys: {
  255. actual: 'option+1',
  256. },
  257. icon: <IconExclamation size="xs" color="gray300" />,
  258. canRunShortcut: token => {
  259. return token?.type === Token.Filter && token.negated;
  260. },
  261. },
  262. {
  263. text: 'Previous',
  264. shortcutType: ShortcutType.Previous,
  265. hotkeys: {
  266. actual: 'option+left',
  267. },
  268. icon: <IconArrow direction="left" size="xs" color="gray300" />,
  269. canRunShortcut: (token, count) => {
  270. return count > 1 || (count > 0 && token?.type !== Token.Filter);
  271. },
  272. },
  273. {
  274. text: 'Next',
  275. shortcutType: ShortcutType.Next,
  276. hotkeys: {
  277. actual: 'option+right',
  278. },
  279. icon: <IconArrow direction="right" size="xs" color="gray300" />,
  280. canRunShortcut: (token, count) => {
  281. return count > 1 || (count > 0 && token?.type !== Token.Filter);
  282. },
  283. },
  284. ];