formulaInput.tsx 4.7 KB

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