sourceField.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514
  1. import {Component, createRef, Fragment} from 'react';
  2. import styled from '@emotion/styled';
  3. import TextField from 'sentry/components/forms/fields/textField';
  4. import TextOverflow from 'sentry/components/textOverflow';
  5. import {t} from 'sentry/locale';
  6. import {space} from 'sentry/styles/space';
  7. import {defined} from 'sentry/utils';
  8. import type {SourceSuggestion} from '../../types';
  9. import {SourceSuggestionType} from '../../types';
  10. import {binarySuggestions, unarySuggestions} from '../../utils';
  11. import SourceSuggestionExamples from './sourceSuggestionExamples';
  12. const defaultHelp = t(
  13. 'Where to look. In the simplest case this can be an attribute name.'
  14. );
  15. type Props = {
  16. isRegExMatchesSelected: boolean;
  17. onChange: (value: string) => void;
  18. suggestions: SourceSuggestion[];
  19. value: string;
  20. error?: string;
  21. onBlur?: (value: string, event: React.FocusEvent<HTMLInputElement>) => void;
  22. };
  23. type State = {
  24. activeSuggestion: number;
  25. fieldValues: Array<SourceSuggestion | SourceSuggestion[]>;
  26. help: string;
  27. hideCaret: boolean;
  28. showSuggestions: boolean;
  29. suggestions: SourceSuggestion[];
  30. };
  31. class SourceField extends Component<Props, State> {
  32. state: State = {
  33. suggestions: [],
  34. fieldValues: [],
  35. activeSuggestion: 0,
  36. showSuggestions: false,
  37. hideCaret: false,
  38. help: defaultHelp,
  39. };
  40. componentDidMount() {
  41. this.loadFieldValues(this.props.value);
  42. this.toggleSuggestions(false);
  43. }
  44. componentDidUpdate(prevProps: Props) {
  45. if (prevProps.suggestions !== this.props.suggestions) {
  46. this.loadFieldValues(this.props.value);
  47. this.toggleSuggestions(false);
  48. }
  49. if (
  50. prevProps.isRegExMatchesSelected !== this.props.isRegExMatchesSelected ||
  51. prevProps.value !== this.props.value
  52. ) {
  53. this.checkPossiblyRegExMatchExpression(this.props.value);
  54. }
  55. }
  56. selectorField = createRef<HTMLDivElement>();
  57. suggestionList = createRef<HTMLUListElement>();
  58. getAllSuggestions() {
  59. return [...this.getValueSuggestions(), ...unarySuggestions, ...binarySuggestions];
  60. }
  61. getValueSuggestions() {
  62. return this.props.suggestions || [];
  63. }
  64. getFilteredSuggestions(value: string, type: SourceSuggestionType) {
  65. let valuesToBeFiltered: SourceSuggestion[] = [];
  66. switch (type) {
  67. case SourceSuggestionType.BINARY: {
  68. valuesToBeFiltered = binarySuggestions;
  69. break;
  70. }
  71. case SourceSuggestionType.VALUE: {
  72. valuesToBeFiltered = this.getValueSuggestions();
  73. break;
  74. }
  75. case SourceSuggestionType.UNARY: {
  76. valuesToBeFiltered = unarySuggestions;
  77. break;
  78. }
  79. default: {
  80. valuesToBeFiltered = [...this.getValueSuggestions(), ...unarySuggestions];
  81. }
  82. }
  83. const filteredSuggestions = valuesToBeFiltered.filter(s =>
  84. s.value.toLowerCase().includes(value.toLowerCase())
  85. );
  86. return filteredSuggestions;
  87. }
  88. // @ts-expect-error TS(7023): 'getNewSuggestions' implicitly has return type 'an... Remove this comment to see the full error message
  89. getNewSuggestions(fieldValues: Array<SourceSuggestion | SourceSuggestion[]>) {
  90. const lastFieldValue = fieldValues[fieldValues.length - 1]!;
  91. const penultimateFieldValue = fieldValues[fieldValues.length - 2]!;
  92. if (Array.isArray(lastFieldValue)) {
  93. // recursion
  94. return this.getNewSuggestions(lastFieldValue);
  95. }
  96. if (Array.isArray(penultimateFieldValue)) {
  97. if (lastFieldValue?.type === 'binary') {
  98. // returns filtered values
  99. return this.getFilteredSuggestions(
  100. lastFieldValue?.value,
  101. SourceSuggestionType.VALUE
  102. );
  103. }
  104. // returns all binaries without any filter
  105. return this.getFilteredSuggestions('', SourceSuggestionType.BINARY);
  106. }
  107. if (lastFieldValue?.type === 'value' && penultimateFieldValue?.type === 'unary') {
  108. // returns filtered values
  109. return this.getFilteredSuggestions(
  110. lastFieldValue?.value,
  111. SourceSuggestionType.VALUE
  112. );
  113. }
  114. if (lastFieldValue?.type === 'unary') {
  115. // returns all values without any filter
  116. return this.getFilteredSuggestions('', SourceSuggestionType.VALUE);
  117. }
  118. if (lastFieldValue?.type === 'string' && penultimateFieldValue?.type === 'value') {
  119. // returns all binaries without any filter
  120. return this.getFilteredSuggestions('', SourceSuggestionType.BINARY);
  121. }
  122. if (
  123. lastFieldValue?.type === 'string' &&
  124. penultimateFieldValue?.type === 'string' &&
  125. !penultimateFieldValue?.value
  126. ) {
  127. // returns all values without any filter
  128. return this.getFilteredSuggestions('', SourceSuggestionType.STRING);
  129. }
  130. if (
  131. (penultimateFieldValue?.type === 'string' && !lastFieldValue?.value) ||
  132. (penultimateFieldValue?.type === 'value' && !lastFieldValue?.value) ||
  133. lastFieldValue?.type === 'binary'
  134. ) {
  135. // returns filtered binaries
  136. return this.getFilteredSuggestions(
  137. lastFieldValue?.value,
  138. SourceSuggestionType.BINARY
  139. );
  140. }
  141. return this.getFilteredSuggestions(lastFieldValue?.value, lastFieldValue?.type);
  142. }
  143. loadFieldValues(newValue: string) {
  144. const fieldValues: Array<SourceSuggestion | SourceSuggestion[]> = [];
  145. const splittedValue = newValue.split(' ');
  146. for (const splittedValueIndex in splittedValue) {
  147. const value = splittedValue[splittedValueIndex]!;
  148. const lastFieldValue = fieldValues[fieldValues.length - 1]!;
  149. if (
  150. lastFieldValue &&
  151. !Array.isArray(lastFieldValue) &&
  152. !lastFieldValue.value &&
  153. !value
  154. ) {
  155. continue;
  156. }
  157. if (value.includes('!') && !!value.split('!')[1]) {
  158. const valueAfterUnaryOperator = value.split('!')[1]!;
  159. const selector = this.getAllSuggestions().find(
  160. s => s.value === valueAfterUnaryOperator
  161. );
  162. if (!selector) {
  163. fieldValues.push([
  164. unarySuggestions[0]!,
  165. {type: SourceSuggestionType.STRING, value: valueAfterUnaryOperator},
  166. ]);
  167. continue;
  168. }
  169. fieldValues.push([unarySuggestions[0]!, selector]);
  170. continue;
  171. }
  172. const selector = this.getAllSuggestions().find(s => s.value === value);
  173. if (selector) {
  174. fieldValues.push(selector);
  175. continue;
  176. }
  177. fieldValues.push({type: SourceSuggestionType.STRING, value});
  178. }
  179. const filteredSuggestions = this.getNewSuggestions(fieldValues);
  180. this.setState({
  181. fieldValues,
  182. activeSuggestion: 0,
  183. suggestions: filteredSuggestions,
  184. });
  185. }
  186. scrollToSuggestion() {
  187. const {activeSuggestion, hideCaret} = this.state;
  188. this.suggestionList?.current?.children[activeSuggestion]!.scrollIntoView({
  189. behavior: 'smooth',
  190. block: 'nearest',
  191. inline: 'start',
  192. });
  193. if (!hideCaret) {
  194. this.setState({
  195. hideCaret: true,
  196. });
  197. }
  198. }
  199. changeParentValue() {
  200. const {onChange} = this.props;
  201. const {fieldValues} = this.state;
  202. const newValue: string[] = [];
  203. for (const index in fieldValues) {
  204. const fieldValue = fieldValues[index]!;
  205. if (Array.isArray(fieldValue)) {
  206. if (fieldValue[0]?.value || fieldValue[1]?.value) {
  207. newValue.push(`${fieldValue[0]?.value ?? ''}${fieldValue[1]?.value ?? ''}`);
  208. }
  209. continue;
  210. }
  211. newValue.push(fieldValue.value);
  212. }
  213. onChange(newValue.join(' '));
  214. }
  215. getNewFieldValues(
  216. suggestion: SourceSuggestion
  217. ): Array<SourceSuggestion | SourceSuggestion[]> {
  218. const fieldValues = [...this.state.fieldValues]!;
  219. const lastFieldValue = fieldValues[fieldValues.length - 1]!;
  220. if (!defined(lastFieldValue)) {
  221. return [suggestion];
  222. }
  223. if (Array.isArray(lastFieldValue)) {
  224. fieldValues[fieldValues.length - 1] = [lastFieldValue[0]!, suggestion];
  225. return fieldValues;
  226. }
  227. if (lastFieldValue?.type === 'unary') {
  228. fieldValues[fieldValues.length - 1] = [lastFieldValue, suggestion];
  229. }
  230. if (lastFieldValue?.type === 'string' && !lastFieldValue?.value) {
  231. fieldValues[fieldValues.length - 1] = suggestion;
  232. return fieldValues;
  233. }
  234. if (suggestion.type === 'value' && lastFieldValue?.value !== suggestion.value) {
  235. return [suggestion];
  236. }
  237. return fieldValues;
  238. }
  239. checkPossiblyRegExMatchExpression(value: string) {
  240. const {isRegExMatchesSelected} = this.props;
  241. const {help} = this.state;
  242. if (isRegExMatchesSelected) {
  243. if (help) {
  244. this.setState({help: ''});
  245. }
  246. return;
  247. }
  248. const isMaybeRegExp = RegExp('^/.*/g?$').test(value);
  249. if (help) {
  250. if (!isMaybeRegExp) {
  251. this.setState({
  252. help: defaultHelp,
  253. });
  254. }
  255. return;
  256. }
  257. if (isMaybeRegExp) {
  258. this.setState({
  259. help: t("You might want to change Data Type's value to 'Regex matches'"),
  260. });
  261. }
  262. }
  263. toggleSuggestions(showSuggestions: boolean) {
  264. this.setState({showSuggestions});
  265. }
  266. handleChange = (value: string) => {
  267. this.loadFieldValues(value);
  268. this.props.onChange(value);
  269. };
  270. handleClickOutside = () => {
  271. this.setState({
  272. showSuggestions: false,
  273. hideCaret: false,
  274. });
  275. };
  276. handleClickSuggestionItem = (suggestion: SourceSuggestion) => {
  277. const fieldValues = this.getNewFieldValues(suggestion);
  278. this.setState(
  279. {
  280. fieldValues,
  281. activeSuggestion: 0,
  282. showSuggestions: false,
  283. hideCaret: false,
  284. },
  285. this.changeParentValue
  286. );
  287. };
  288. handleKeyDown = (_value: string, event: React.KeyboardEvent<HTMLInputElement>) => {
  289. event.persist();
  290. const {key} = event;
  291. const {activeSuggestion, suggestions} = this.state;
  292. if (key === 'Backspace' || key === ' ') {
  293. this.toggleSuggestions(true);
  294. return;
  295. }
  296. if (key === 'Enter') {
  297. this.handleClickSuggestionItem(suggestions[activeSuggestion]!);
  298. return;
  299. }
  300. if (key === 'ArrowUp') {
  301. if (activeSuggestion === 0) {
  302. return;
  303. }
  304. this.setState({activeSuggestion: activeSuggestion - 1}, () => {
  305. this.scrollToSuggestion();
  306. });
  307. return;
  308. }
  309. if (key === 'ArrowDown') {
  310. if (activeSuggestion === suggestions.length - 1) {
  311. return;
  312. }
  313. this.setState({activeSuggestion: activeSuggestion + 1}, () => {
  314. this.scrollToSuggestion();
  315. });
  316. return;
  317. }
  318. };
  319. handleFocus = () => {
  320. this.toggleSuggestions(true);
  321. };
  322. render() {
  323. const {error, value, onBlur} = this.props;
  324. const {showSuggestions, suggestions, activeSuggestion, hideCaret, help} = this.state;
  325. return (
  326. <Wrapper ref={this.selectorField} hideCaret={hideCaret}>
  327. <StyledTextField
  328. data-test-id="source-field"
  329. label={t('Source')}
  330. name="source"
  331. placeholder={t('Enter a custom attribute, variable or header name')}
  332. onChange={this.handleChange}
  333. autoComplete="off"
  334. value={value}
  335. error={error}
  336. help={help}
  337. onKeyDown={this.handleKeyDown}
  338. onBlur={onBlur}
  339. onFocus={this.handleFocus}
  340. inline={false}
  341. flexibleControlStateSize
  342. stacked
  343. required
  344. showHelpInTooltip
  345. />
  346. {showSuggestions && suggestions.length > 0 && (
  347. <Fragment>
  348. <Suggestions
  349. ref={this.suggestionList}
  350. error={error}
  351. data-test-id="source-suggestions"
  352. >
  353. {suggestions.slice(0, 50).map((suggestion, index) => (
  354. <Suggestion
  355. key={suggestion.value}
  356. onClick={event => {
  357. event.preventDefault();
  358. this.handleClickSuggestionItem(suggestion);
  359. }}
  360. active={index === activeSuggestion}
  361. tabIndex={-1}
  362. >
  363. <TextOverflow>{suggestion.value}</TextOverflow>
  364. {suggestion.description && (
  365. <SuggestionDescription>
  366. (<TextOverflow>{suggestion.description}</TextOverflow>)
  367. </SuggestionDescription>
  368. )}
  369. {suggestion.examples && suggestion.examples.length > 0 && (
  370. <SourceSuggestionExamples
  371. examples={suggestion.examples}
  372. sourceName={suggestion.value}
  373. />
  374. )}
  375. </Suggestion>
  376. ))}
  377. </Suggestions>
  378. <SuggestionsOverlay onClick={this.handleClickOutside} />
  379. </Fragment>
  380. )}
  381. </Wrapper>
  382. );
  383. }
  384. }
  385. export default SourceField;
  386. const Wrapper = styled('div')<{hideCaret?: boolean}>`
  387. position: relative;
  388. width: 100%;
  389. ${p => p.hideCaret && `caret-color: transparent;`}
  390. `;
  391. const StyledTextField = styled(TextField)`
  392. z-index: 1002;
  393. :focus {
  394. outline: none;
  395. }
  396. `;
  397. const Suggestions = styled('ul')<{error?: string}>`
  398. position: absolute;
  399. width: ${p => (p.error ? 'calc(100% - 34px)' : '100%')};
  400. padding-left: 0;
  401. list-style: none;
  402. margin-bottom: 0;
  403. box-shadow: 0 2px 0 rgba(37, 11, 54, 0.04);
  404. border: 1px solid ${p => p.theme.border};
  405. border-radius: 0 0 ${space(0.5)} ${space(0.5)};
  406. background: ${p => p.theme.background};
  407. top: 63px;
  408. left: 0;
  409. z-index: 1002;
  410. overflow: hidden;
  411. max-height: 200px;
  412. overflow-y: auto;
  413. `;
  414. const Suggestion = styled('li')<{active: boolean}>`
  415. display: grid;
  416. grid-template-columns: auto 1fr max-content;
  417. gap: ${space(1)};
  418. border-bottom: 1px solid ${p => p.theme.border};
  419. padding: ${space(1)} ${space(2)};
  420. font-size: ${p => p.theme.fontSizeMedium};
  421. cursor: pointer;
  422. background: ${p => (p.active ? p.theme.backgroundSecondary : p.theme.background)};
  423. :hover {
  424. background: ${p =>
  425. p.active ? p.theme.backgroundSecondary : p.theme.backgroundSecondary};
  426. }
  427. `;
  428. const SuggestionDescription = styled('div')`
  429. display: flex;
  430. overflow: hidden;
  431. color: ${p => p.theme.gray300};
  432. line-height: 1.2;
  433. `;
  434. const SuggestionsOverlay = styled('div')`
  435. position: fixed;
  436. top: 0;
  437. left: 0;
  438. right: 0;
  439. bottom: 0;
  440. z-index: 1001;
  441. `;