utils.tsx 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. import {useCallback} from 'react';
  2. import {getFocusableTreeWalker} from '@react-aria/focus';
  3. import type {ListState} from '@react-stately/list';
  4. import type {Node} from '@react-types/shared';
  5. import {
  6. filterTypeConfig,
  7. interchangeableFilterOperators,
  8. type ParseResult,
  9. type ParseResultToken,
  10. parseSearch,
  11. type SearchConfig,
  12. type TermOperator,
  13. Token,
  14. type TokenResult,
  15. } from 'sentry/components/searchSyntax/parser';
  16. import type {Tag, TagCollection} from 'sentry/types';
  17. import {escapeDoubleQuotes} from 'sentry/utils';
  18. import {FieldValueType, getFieldDefinition} from 'sentry/utils/fields';
  19. export const INTERFACE_TYPE_LOCALSTORAGE_KEY = 'search-query-builder-interface';
  20. function getSearchConfigFromKeys(keys: TagCollection): Partial<SearchConfig> {
  21. const config = {
  22. booleanKeys: new Set<string>(),
  23. numericKeys: new Set<string>(),
  24. dateKeys: new Set<string>(),
  25. durationKeys: new Set<string>(),
  26. } satisfies Partial<SearchConfig>;
  27. for (const key in keys) {
  28. const fieldDef = getFieldDefinition(key);
  29. if (!fieldDef) {
  30. continue;
  31. }
  32. switch (fieldDef.valueType) {
  33. case FieldValueType.BOOLEAN:
  34. config.booleanKeys.add(key);
  35. break;
  36. case FieldValueType.NUMBER:
  37. case FieldValueType.INTEGER:
  38. config.numericKeys.add(key);
  39. break;
  40. case FieldValueType.DATE:
  41. config.dateKeys.add(key);
  42. break;
  43. case FieldValueType.DURATION:
  44. config.durationKeys.add(key);
  45. break;
  46. default:
  47. break;
  48. }
  49. }
  50. return config;
  51. }
  52. export function parseQueryBuilderValue(
  53. value: string,
  54. options?: {keys: TagCollection}
  55. ): ParseResult | null {
  56. return collapseTextTokens(
  57. parseSearch(value || ' ', {
  58. flattenParenGroups: true,
  59. ...getSearchConfigFromKeys(options?.keys ?? {}),
  60. })
  61. );
  62. }
  63. /**
  64. * Generates a unique key for the given token.
  65. *
  66. * It's important that the key is as stable as possible. Since we derive tokens
  67. * from the a simple query string, this is difficult to guarantee. The best we
  68. * can do is to use the token type and which iteration of that type it is.
  69. *
  70. * Example for query "is:unresolved foo assignee:me bar":
  71. * Keys: ["freeText:0", "filter:0", "freeText:1" "filter:1", "freeText:2"]
  72. */
  73. export function makeTokenKey(token: ParseResultToken, allTokens: ParseResult | null) {
  74. const tokenTypeIndex =
  75. allTokens?.filter(t => t.type === token.type).indexOf(token) ?? 0;
  76. return `${token.type}:${tokenTypeIndex}`;
  77. }
  78. const isSimpleTextToken = (
  79. token: ParseResultToken
  80. ): token is TokenResult<Token.FREE_TEXT> | TokenResult<Token.SPACES> => {
  81. return [Token.FREE_TEXT, Token.SPACES].includes(token.type);
  82. };
  83. export function getKeyLabel(key: Tag) {
  84. return key.alias ?? key.key;
  85. }
  86. /**
  87. * Collapse adjacent FREE_TEXT and SPACES tokens into a single token.
  88. * This is useful for rendering the minimum number of inputs in the UI.
  89. */
  90. export function collapseTextTokens(tokens: ParseResult | null) {
  91. if (!tokens) {
  92. return null;
  93. }
  94. return tokens.reduce<ParseResult>((acc, token) => {
  95. // For our purposes, SPACES are equivalent to FREE_TEXT
  96. // Combining them ensures that keys don't change when text is added or removed,
  97. // which would cause the cursor to jump around.
  98. if (isSimpleTextToken(token)) {
  99. token.type = Token.FREE_TEXT;
  100. }
  101. if (acc.length === 0) {
  102. return [token];
  103. }
  104. const lastToken = acc[acc.length - 1];
  105. if (isSimpleTextToken(token) && isSimpleTextToken(lastToken)) {
  106. lastToken.value += token.value;
  107. lastToken.text += token.text;
  108. lastToken.location.end = token.location.end;
  109. return acc;
  110. }
  111. return [...acc, token];
  112. }, []);
  113. }
  114. export function getValidOpsForFilter(
  115. filterToken: TokenResult<Token.FILTER>
  116. ): readonly TermOperator[] {
  117. // If the token is invalid we want to use the possible expected types as our filter type
  118. const validTypes = filterToken.invalid?.expectedType ?? [filterToken.filter];
  119. // Determine any interchangeable filter types for our valid types
  120. const interchangeableTypes = validTypes.map(
  121. type => interchangeableFilterOperators[type] ?? []
  122. );
  123. // Combine all types
  124. const allValidTypes = [...new Set([...validTypes, ...interchangeableTypes.flat()])];
  125. // Find all valid operations
  126. const validOps = new Set<TermOperator>(
  127. allValidTypes.flatMap(type => filterTypeConfig[type].validOps)
  128. );
  129. return [...validOps];
  130. }
  131. export function escapeTagValue(value: string): string {
  132. // Wrap in quotes if there is a space
  133. return value.includes(' ') || value.includes('"')
  134. ? `"${escapeDoubleQuotes(value)}"`
  135. : value;
  136. }
  137. export function unescapeTagValue(value: string): string {
  138. return value.replace(/\\"/g, '"');
  139. }
  140. export function formatFilterValue(token: TokenResult<Token.FILTER>['value']): string {
  141. switch (token.type) {
  142. case Token.VALUE_TEXT:
  143. return unescapeTagValue(token.value);
  144. default:
  145. return token.text;
  146. }
  147. }
  148. export function shiftFocusToChild(
  149. element: HTMLElement,
  150. item: Node<ParseResultToken>,
  151. state: ListState<ParseResultToken>
  152. ) {
  153. // Ensure that the state is updated correctly
  154. state.selectionManager.setFocusedKey(item.key);
  155. // When this row gains focus, immediately shift focus to the input
  156. const walker = getFocusableTreeWalker(element);
  157. const nextNode = walker.nextNode();
  158. if (nextNode) {
  159. (nextNode as HTMLElement).focus();
  160. }
  161. }
  162. export function useShiftFocusToChild(
  163. item: Node<ParseResultToken>,
  164. state: ListState<ParseResultToken>
  165. ) {
  166. const onFocus = useCallback(
  167. (e: React.FocusEvent<HTMLDivElement, Element>) => {
  168. shiftFocusToChild(e.currentTarget, item, state);
  169. },
  170. [item, state]
  171. );
  172. return {
  173. shiftFocusProps: {onFocus},
  174. };
  175. }