sourceField.tsx 14 KB

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