formulaInput.tsx 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. import {useCallback, useEffect, useMemo, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import debounce from 'lodash/debounce';
  4. import Input, {inputStyles} from 'sentry/components/input';
  5. import {t} from 'sentry/locale';
  6. import {unescapeMetricsFormula} from 'sentry/utils/metrics';
  7. import {FormularFormatter} from 'sentry/views/ddm/formulaParser/formatter';
  8. import {joinTokens, parseFormula} from 'sentry/views/ddm/formulaParser/parser';
  9. import {type TokenList, TokenType} from 'sentry/views/ddm/formulaParser/types';
  10. interface Props extends Omit<React.ComponentProps<typeof Input>, 'onChange' | 'value'> {
  11. availableVariables: Set<string>;
  12. formulaVariables: Set<string>;
  13. onChange: (formula: string) => void;
  14. value: string;
  15. }
  16. function escapeVariables(tokens: TokenList): TokenList {
  17. return tokens.map(token => {
  18. if (token.type !== TokenType.VARIABLE) {
  19. return token;
  20. }
  21. return {
  22. ...token,
  23. content: `$${token.content}`,
  24. };
  25. });
  26. }
  27. function equalizeWhitespace(formula: TokenList): TokenList {
  28. return formula.map(token => {
  29. // Ensure equal spacing
  30. if (token.type === TokenType.WHITESPACE) {
  31. return {...token, content: ' '};
  32. }
  33. return token;
  34. });
  35. }
  36. export function FormulaInput({
  37. availableVariables,
  38. formulaVariables,
  39. value: valueProp,
  40. onChange,
  41. ...props
  42. }: Props) {
  43. const [errors, setErrors] = useState<any>([]);
  44. const [showErrors, setIsValidationEnabled] = useState(false);
  45. const [value, setValue] = useState<string>(() => unescapeMetricsFormula(valueProp));
  46. const validateVariable = useCallback(
  47. (variable: string): string | null => {
  48. if (formulaVariables.has(variable)) {
  49. return t('Equations cannot reference other equations.', variable);
  50. }
  51. if (!availableVariables.has(variable)) {
  52. return t('Unknown query "%s"', variable);
  53. }
  54. return null;
  55. },
  56. [availableVariables, formulaVariables]
  57. );
  58. const parseAndValidateFormula = useCallback(
  59. (formula: string): TokenList | null => {
  60. let tokens: TokenList = [];
  61. const newErrors: any[] = [];
  62. if (formula) {
  63. try {
  64. tokens = parseFormula(formula);
  65. } catch (err) {
  66. newErrors.push({
  67. message: err.message,
  68. start: err.location.start.offset,
  69. });
  70. }
  71. }
  72. // validate variables
  73. let charCount = 0;
  74. tokens.forEach(token => {
  75. if (token.type === TokenType.VARIABLE) {
  76. const error = validateVariable(token.content);
  77. if (error) {
  78. newErrors.push({
  79. message: error,
  80. start: charCount,
  81. end: charCount + token.content.length,
  82. });
  83. }
  84. }
  85. charCount += token.content.length;
  86. });
  87. newErrors.sort((a, b) => a.start - b.start);
  88. setErrors(newErrors);
  89. if (newErrors.length > 0) {
  90. return null;
  91. }
  92. return tokens;
  93. },
  94. [validateVariable]
  95. );
  96. useEffect(() => {
  97. setIsValidationEnabled(false);
  98. const timeoutId = setTimeout(() => {
  99. setIsValidationEnabled(true);
  100. }, 500);
  101. return () => {
  102. clearTimeout(timeoutId);
  103. };
  104. }, [value]);
  105. // Parse and validate formula everytime the validation criteria changes
  106. useEffect(() => {
  107. parseAndValidateFormula(value);
  108. // eslint-disable-next-line react-hooks/exhaustive-deps
  109. }, [parseAndValidateFormula]);
  110. const handleChange = useMemo(
  111. () =>
  112. debounce((e: React.ChangeEvent<HTMLInputElement>) => {
  113. const newValue = e.target.value.trim();
  114. const tokens = parseAndValidateFormula(newValue);
  115. if (!tokens) {
  116. return;
  117. }
  118. onChange(joinTokens(equalizeWhitespace(escapeVariables(tokens))));
  119. }, 200),
  120. [onChange, parseAndValidateFormula]
  121. );
  122. return (
  123. <Wrapper>
  124. <StyledInput
  125. {...props}
  126. monospace
  127. hasError={showErrors && errors.length > 0}
  128. defaultValue={value}
  129. onChange={e => {
  130. setValue(e.target.value);
  131. handleChange(e);
  132. }}
  133. />
  134. <RendererOverlay monospace>
  135. <FormularFormatter formula={value} errors={showErrors ? errors : []} />
  136. </RendererOverlay>
  137. </Wrapper>
  138. );
  139. }
  140. const Wrapper = styled('div')`
  141. position: relative;
  142. `;
  143. const RendererOverlay = styled('div')`
  144. ${inputStyles}
  145. border-color: transparent;
  146. position: absolute;
  147. top: 0;
  148. right: 0;
  149. bottom: 0;
  150. left: 0;
  151. align-items: center;
  152. justify-content: center;
  153. pointer-events: none;
  154. background: none;
  155. white-space: nowrap;
  156. overflow: hidden;
  157. resize: none;
  158. `;
  159. const StyledInput = styled(Input)<{hasError: boolean}>`
  160. caret-color: ${p => p.theme.subText};
  161. color: transparent;
  162. ${p =>
  163. p.hasError &&
  164. `
  165. border-color: ${p.theme.error};
  166. &:focus {
  167. border-color: ${p.theme.errorFocus};
  168. box-shadow: none;
  169. }
  170. `}
  171. `;