sourceField.tsx 14 KB

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