input.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443
  1. import {Fragment, useCallback, useMemo, useRef, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {mergeProps} from '@react-aria/utils';
  4. import {Item, Section} from '@react-stately/collections';
  5. import type {ListState} from '@react-stately/list';
  6. import type {Node} from '@react-types/shared';
  7. import {getEscapedKey} from 'sentry/components/compactSelect/utils';
  8. import {SearchQueryBuilderCombobox} from 'sentry/components/searchQueryBuilder/combobox';
  9. import {useSearchQueryBuilder} from 'sentry/components/searchQueryBuilder/context';
  10. import type {
  11. FilterKeySection,
  12. FocusOverride,
  13. } from 'sentry/components/searchQueryBuilder/types';
  14. import {useQueryBuilderGridItem} from 'sentry/components/searchQueryBuilder/useQueryBuilderGridItem';
  15. import {replaceTokenWithPadding} from 'sentry/components/searchQueryBuilder/useQueryBuilderState';
  16. import {useShiftFocusToChild} from 'sentry/components/searchQueryBuilder/utils';
  17. import {
  18. type ParseResultToken,
  19. Token,
  20. type TokenResult,
  21. } from 'sentry/components/searchSyntax/parser';
  22. import {t} from 'sentry/locale';
  23. import {space} from 'sentry/styles/space';
  24. import type {Tag} from 'sentry/types/group';
  25. import {FieldValueType, getFieldDefinition} from 'sentry/utils/fields';
  26. import {toTitleCase} from 'sentry/utils/string/toTitleCase';
  27. type SearchQueryBuilderInputProps = {
  28. item: Node<ParseResultToken>;
  29. state: ListState<ParseResultToken>;
  30. token: TokenResult<Token.FREE_TEXT> | TokenResult<Token.SPACES>;
  31. };
  32. type SearchQueryBuilderInputInternalProps = {
  33. item: Node<ParseResultToken>;
  34. state: ListState<ParseResultToken>;
  35. tabIndex: number;
  36. token: TokenResult<Token.FREE_TEXT> | TokenResult<Token.SPACES>;
  37. };
  38. function getWordAtCursorPosition(value: string, cursorPosition: number) {
  39. const words = value.split(' ');
  40. let characterCount = 0;
  41. for (const word of words) {
  42. characterCount += word.length + 1;
  43. if (characterCount >= cursorPosition) {
  44. return word;
  45. }
  46. }
  47. return value;
  48. }
  49. function getInitialFilterText(key: string) {
  50. const fieldDef = getFieldDefinition(key);
  51. if (!fieldDef) {
  52. return `${key}:`;
  53. }
  54. switch (fieldDef.valueType) {
  55. case FieldValueType.BOOLEAN:
  56. return `${key}:true`;
  57. case FieldValueType.INTEGER:
  58. case FieldValueType.NUMBER:
  59. return `${key}:>100`;
  60. case FieldValueType.DATE:
  61. return `${key}:-24h`;
  62. case FieldValueType.STRING:
  63. default:
  64. return `${key}:`;
  65. }
  66. }
  67. /**
  68. * Replaces the focused word (at cursorPosition) with the selected filter key.
  69. *
  70. * Example:
  71. * replaceFocusedWordWithFilter('before brow after', 9, 'browser.name') => 'before browser.name: after'
  72. */
  73. function replaceFocusedWordWithFilter(
  74. value: string,
  75. cursorPosition: number,
  76. key: string
  77. ) {
  78. const words = value.split(' ');
  79. let characterCount = 0;
  80. for (const word of words) {
  81. characterCount += word.length + 1;
  82. if (characterCount >= cursorPosition) {
  83. return (
  84. value.slice(0, characterCount - word.length - 1).trim() +
  85. ` ${getInitialFilterText(key)} ` +
  86. value.slice(characterCount).trim()
  87. ).trim();
  88. }
  89. }
  90. return value;
  91. }
  92. /**
  93. * Takes a string that contains a filter value `<key>:` and replaces with any aliases that may exist.
  94. *
  95. * Example:
  96. * replaceAliasedFilterKeys('foo issue: bar', {'status': 'is'}) => 'foo is: bar'
  97. */
  98. function replaceAliasedFilterKeys(value: string, aliasToKeyMap: Record<string, string>) {
  99. const key = value.match(/(\w+):/);
  100. const matchedKey = key?.[1];
  101. if (matchedKey && aliasToKeyMap[matchedKey]) {
  102. const actualKey = aliasToKeyMap[matchedKey];
  103. const replacedValue = value.replace(
  104. `${matchedKey}:`,
  105. getInitialFilterText(actualKey)
  106. );
  107. return replacedValue;
  108. }
  109. return value;
  110. }
  111. function getItemsBySection(filterKeySections: FilterKeySection[]) {
  112. return filterKeySections.map(section => {
  113. return {
  114. key: section.value,
  115. value: section.value,
  116. title: section.label,
  117. options: section.children.map(tag => {
  118. const fieldDefinition = getFieldDefinition(tag.key);
  119. return {
  120. key: getEscapedKey(tag.key),
  121. label: tag.key,
  122. value: tag.key,
  123. textValue: tag.key,
  124. hideCheck: true,
  125. showDetailsInOverlay: true,
  126. details: fieldDefinition?.desc ? <KeyDescription tag={tag} /> : null,
  127. };
  128. }),
  129. };
  130. });
  131. }
  132. function countPreviousItemsOfType({
  133. state,
  134. type,
  135. }: {
  136. state: ListState<ParseResultToken>;
  137. type: Token;
  138. }) {
  139. const itemKeys = [...state.collection.getKeys()];
  140. const currentIndex = itemKeys.indexOf(state.selectionManager.focusedKey);
  141. return itemKeys.slice(0, currentIndex).reduce<number>((count, next) => {
  142. if (next.toString().includes(type)) {
  143. return count + 1;
  144. }
  145. return count;
  146. }, 0);
  147. }
  148. function calculateNextFocusForFilter(state: ListState<ParseResultToken>): FocusOverride {
  149. const numPreviousFilterItems = countPreviousItemsOfType({state, type: Token.FILTER});
  150. return {
  151. itemKey: `${Token.FILTER}:${numPreviousFilterItems}`,
  152. part: 'value',
  153. };
  154. }
  155. function calculateNextFocusForParen(item: Node<ParseResultToken>): FocusOverride {
  156. const [, tokenTypeIndexStr] = item.key.toString().split(':');
  157. const tokenTypeIndex = parseInt(tokenTypeIndexStr, 10);
  158. return {
  159. itemKey: `${Token.FREE_TEXT}:${tokenTypeIndex + 1}`,
  160. };
  161. }
  162. function KeyDescription({tag}: {tag: Tag}) {
  163. const fieldDefinition = getFieldDefinition(tag.key);
  164. if (!fieldDefinition || !fieldDefinition.desc) {
  165. return null;
  166. }
  167. return (
  168. <DescriptionWrapper>
  169. <div>{fieldDefinition.desc}</div>
  170. <Separator />
  171. <DescriptionList>
  172. {tag.alias ? (
  173. <Fragment>
  174. <Term>{t('Alias')}</Term>
  175. <Details>{tag.key}</Details>
  176. </Fragment>
  177. ) : null}
  178. {fieldDefinition.valueType ? (
  179. <Fragment>
  180. <Term>{t('Type')}</Term>
  181. <Details>{toTitleCase(fieldDefinition.valueType)}</Details>
  182. </Fragment>
  183. ) : null}
  184. </DescriptionList>
  185. </DescriptionWrapper>
  186. );
  187. }
  188. function SearchQueryBuilderInputInternal({
  189. item,
  190. token,
  191. tabIndex,
  192. state,
  193. }: SearchQueryBuilderInputInternalProps) {
  194. const trimmedTokenValue = token.text.trim();
  195. const [inputValue, setInputValue] = useState(trimmedTokenValue);
  196. // TODO(malwilley): Use input ref to update cursor position on mount
  197. const [selectionIndex, setSelectionIndex] = useState(0);
  198. const resetInputValue = useCallback(() => {
  199. setInputValue(trimmedTokenValue);
  200. // TODO(malwilley): Reset cursor position using ref
  201. }, [trimmedTokenValue]);
  202. const filterValue = getWordAtCursorPosition(inputValue, selectionIndex);
  203. const {query, keys, filterKeySections, dispatch, onSearch} = useSearchQueryBuilder();
  204. const aliasToKeyMap = useMemo(() => {
  205. return Object.fromEntries(Object.values(keys).map(key => [key.alias, key.key]));
  206. }, [keys]);
  207. const sections = useMemo(
  208. () => getItemsBySection(filterKeySections),
  209. [filterKeySections]
  210. );
  211. // When token value changes, reset the input value
  212. const [prevValue, setPrevValue] = useState(inputValue);
  213. if (trimmedTokenValue !== prevValue) {
  214. setPrevValue(trimmedTokenValue);
  215. setInputValue(trimmedTokenValue);
  216. }
  217. const onKeyDown = useCallback(
  218. (e: React.KeyboardEvent<HTMLInputElement>) => {
  219. // At start and pressing backspace, focus the previous full token
  220. if (
  221. e.currentTarget.selectionStart === 0 &&
  222. e.currentTarget.selectionEnd === 0 &&
  223. e.key === 'Backspace'
  224. ) {
  225. if (state.collection.getKeyBefore(item.key)) {
  226. state.selectionManager.setFocusedKey(state.collection.getKeyBefore(item.key));
  227. }
  228. }
  229. // At end and pressing delete, focus the next full token
  230. if (
  231. e.currentTarget.selectionStart === e.currentTarget.value.length &&
  232. e.currentTarget.selectionEnd === e.currentTarget.value.length &&
  233. e.key === 'Delete'
  234. ) {
  235. if (state.collection.getKeyAfter(item.key)) {
  236. state.selectionManager.setFocusedKey(state.collection.getKeyAfter(item.key));
  237. }
  238. }
  239. },
  240. [item.key, state.collection, state.selectionManager]
  241. );
  242. const onPaste = useCallback(
  243. (e: React.ClipboardEvent<HTMLInputElement>) => {
  244. e.preventDefault();
  245. e.stopPropagation();
  246. const text = e.clipboardData.getData('text/plain').replace('\n', '').trim();
  247. dispatch({
  248. type: 'PASTE_FREE_TEXT',
  249. token,
  250. text: replaceAliasedFilterKeys(text, aliasToKeyMap),
  251. });
  252. resetInputValue();
  253. },
  254. [aliasToKeyMap, dispatch, resetInputValue, token]
  255. );
  256. return (
  257. <SearchQueryBuilderCombobox
  258. items={sections}
  259. onOptionSelected={value => {
  260. dispatch({
  261. type: 'UPDATE_FREE_TEXT',
  262. token,
  263. text: replaceFocusedWordWithFilter(inputValue, selectionIndex, value),
  264. focusOverride: calculateNextFocusForFilter(state),
  265. });
  266. resetInputValue();
  267. }}
  268. onCustomValueBlurred={value => {
  269. dispatch({type: 'UPDATE_FREE_TEXT', token, text: value});
  270. resetInputValue();
  271. }}
  272. onCustomValueCommitted={value => {
  273. dispatch({type: 'UPDATE_FREE_TEXT', token, text: value});
  274. resetInputValue();
  275. // Because the query does not change until a subsequent render,
  276. // we need to do the replacement that is does in the ruducer here
  277. onSearch?.(replaceTokenWithPadding(query, token, value));
  278. }}
  279. onExit={() => {
  280. if (inputValue !== token.value.trim()) {
  281. dispatch({type: 'UPDATE_FREE_TEXT', token, text: inputValue});
  282. resetInputValue();
  283. }
  284. }}
  285. inputValue={inputValue}
  286. filterValue={filterValue}
  287. token={token}
  288. inputLabel={t('Add a search term')}
  289. onInputChange={e => {
  290. if (e.target.value.includes('(') || e.target.value.includes(')')) {
  291. dispatch({
  292. type: 'UPDATE_FREE_TEXT',
  293. token,
  294. text: e.target.value,
  295. focusOverride: calculateNextFocusForParen(item),
  296. });
  297. resetInputValue();
  298. return;
  299. }
  300. if (e.target.value.includes(':')) {
  301. dispatch({
  302. type: 'UPDATE_FREE_TEXT',
  303. token,
  304. text: replaceAliasedFilterKeys(e.target.value, aliasToKeyMap),
  305. focusOverride: calculateNextFocusForFilter(state),
  306. });
  307. resetInputValue();
  308. return;
  309. }
  310. setInputValue(e.target.value);
  311. setSelectionIndex(e.target.selectionStart ?? 0);
  312. }}
  313. onKeyDown={onKeyDown}
  314. tabIndex={tabIndex}
  315. maxOptions={50}
  316. onPaste={onPaste}
  317. displayTabbedMenu={inputValue.length === 0}
  318. >
  319. {section => (
  320. <Section title={section.title} key={section.key}>
  321. {section.options.map(child => (
  322. <Item {...child} key={child.key}>
  323. {child.label}
  324. </Item>
  325. ))}
  326. </Section>
  327. )}
  328. </SearchQueryBuilderCombobox>
  329. );
  330. }
  331. export function SearchQueryBuilderInput({
  332. token,
  333. state,
  334. item,
  335. }: SearchQueryBuilderInputProps) {
  336. const ref = useRef<HTMLDivElement>(null);
  337. const {rowProps, gridCellProps} = useQueryBuilderGridItem(item, state, ref);
  338. const {shiftFocusProps} = useShiftFocusToChild(item, state);
  339. const isFocused = item.key === state.selectionManager.focusedKey;
  340. return (
  341. <Row {...mergeProps(rowProps, shiftFocusProps)} ref={ref} tabIndex={-1}>
  342. <GridCell {...gridCellProps} onClick={e => e.stopPropagation()}>
  343. <SearchQueryBuilderInputInternal
  344. item={item}
  345. state={state}
  346. token={token}
  347. tabIndex={isFocused ? 0 : -1}
  348. />
  349. </GridCell>
  350. </Row>
  351. );
  352. }
  353. const Row = styled('div')`
  354. display: flex;
  355. align-items: stretch;
  356. height: 24px;
  357. &:last-child {
  358. flex-grow: 1;
  359. }
  360. `;
  361. const GridCell = styled('div')`
  362. display: flex;
  363. align-items: stretch;
  364. height: 100%;
  365. width: 100%;
  366. input {
  367. padding: 0 ${space(0.5)};
  368. min-width: 9px;
  369. width: 100%;
  370. }
  371. `;
  372. const DescriptionWrapper = styled('div')`
  373. padding: ${space(1)} ${space(1.5)};
  374. max-width: 220px;
  375. `;
  376. const Separator = styled('hr')`
  377. border-top: 1px solid ${p => p.theme.border};
  378. margin: ${space(1)} 0;
  379. `;
  380. const DescriptionList = styled('dl')`
  381. display: grid;
  382. grid-template-columns: max-content 1fr;
  383. gap: ${space(0.5)};
  384. margin: 0;
  385. `;
  386. const Term = styled('dt')`
  387. color: ${p => p.theme.subText};
  388. font-weight: ${p => p.theme.fontWeightNormal};
  389. `;
  390. const Details = styled('dd')``;