utils.tsx 8.6 KB

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