queryField.tsx 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817
  1. import {Component, createRef} from 'react';
  2. import {components, SingleValueProps} from 'react-select';
  3. import styled from '@emotion/styled';
  4. import cloneDeep from 'lodash/cloneDeep';
  5. import Input, {InputProps} from 'sentry/components/forms/controls/input';
  6. import SelectControl, {ControlProps} from 'sentry/components/forms/selectControl';
  7. import Tag from 'sentry/components/tag';
  8. import Tooltip from 'sentry/components/tooltip';
  9. import {IconWarning} from 'sentry/icons';
  10. import {t} from 'sentry/locale';
  11. import {pulse} from 'sentry/styles/animations';
  12. import space from 'sentry/styles/space';
  13. import {SelectValue} from 'sentry/types';
  14. import {
  15. AggregateParameter,
  16. AggregationKey,
  17. AGGREGATIONS,
  18. Column,
  19. ColumnType,
  20. DEPRECATED_FIELDS,
  21. QueryFieldValue,
  22. ValidateColumnTypes,
  23. } from 'sentry/utils/discover/fields';
  24. import {SESSIONS_OPERATIONS} from 'sentry/views/dashboardsV2/widgetBuilder/releaseWidget/fields';
  25. import ArithmeticInput from './arithmeticInput';
  26. import {FieldValue, FieldValueColumns, FieldValueKind} from './types';
  27. export type FieldValueOption = SelectValue<FieldValue>;
  28. type FieldOptions = Record<string, FieldValueOption>;
  29. // Intermediate type that combines the current column
  30. // data with the AggregateParameter type.
  31. type ParameterDescription =
  32. | {
  33. dataType: ColumnType;
  34. kind: 'value';
  35. required: boolean;
  36. value: string;
  37. placeholder?: string;
  38. }
  39. | {
  40. kind: 'column';
  41. options: FieldValueOption[];
  42. required: boolean;
  43. value: FieldValue | null;
  44. }
  45. | {
  46. dataType: string;
  47. kind: 'dropdown';
  48. options: SelectValue<string>[];
  49. required: boolean;
  50. value: string;
  51. placeholder?: string;
  52. };
  53. type Props = {
  54. fieldOptions: FieldOptions;
  55. fieldValue: QueryFieldValue;
  56. onChange: (fieldValue: QueryFieldValue) => void;
  57. className?: string;
  58. disabled?: boolean;
  59. error?: string;
  60. /**
  61. * Function to filter the options that are used as parameters for function/aggregate.
  62. */
  63. filterAggregateParameters?: (
  64. option: FieldValueOption,
  65. fieldValue?: QueryFieldValue
  66. ) => boolean;
  67. /**
  68. * Filter the options in the primary selector. Useful if you only want to
  69. * show a subset of selectable items.
  70. *
  71. * NOTE: This is different from passing an already filtered fieldOptions
  72. * list, as tag items in the list may be used as parameters to functions.
  73. */
  74. filterPrimaryOptions?: (option: FieldValueOption) => boolean;
  75. /**
  76. * The number of columns to render. Columns that do not have a parameter will
  77. * render an empty parameter placeholder. Leave blank to avoid adding spacers.
  78. */
  79. gridColumns?: number;
  80. hideParameterSelector?: boolean;
  81. hidePrimarySelector?: boolean;
  82. /**
  83. * Whether or not to add labels inside of the input fields, currently only
  84. * used for the metric alert builder.
  85. */
  86. inFieldLabels?: boolean;
  87. /**
  88. * This will be displayed in the select if there are no fields
  89. */
  90. noFieldsMessage?: string;
  91. otherColumns?: Column[];
  92. placeholder?: string;
  93. /**
  94. * Whether or not to add the tag explaining the FieldValueKind of each field
  95. */
  96. shouldRenderTag?: boolean;
  97. skipParameterPlaceholder?: boolean;
  98. takeFocus?: boolean;
  99. };
  100. // Type for completing generics in react-select
  101. type OptionType = {
  102. label: string;
  103. value: FieldValue;
  104. };
  105. class QueryField extends Component<Props> {
  106. FieldSelectComponents = {
  107. SingleValue: ({data, ...props}: SingleValueProps<OptionType>) => {
  108. return (
  109. <components.SingleValue data={data} {...props}>
  110. <span data-test-id="label">{data.label}</span>
  111. {data.value && this.renderTag(data.value.kind, data.label)}
  112. </components.SingleValue>
  113. );
  114. },
  115. };
  116. FieldSelectStyles = {
  117. singleValue(provided: React.CSSProperties) {
  118. const custom = {
  119. display: 'flex',
  120. justifyContent: 'space-between',
  121. alignItems: 'center',
  122. width: 'calc(100% - 10px)',
  123. };
  124. return {...provided, ...custom};
  125. },
  126. };
  127. handleFieldChange = (selected?: FieldValueOption | null) => {
  128. if (!selected) {
  129. return;
  130. }
  131. const {value} = selected;
  132. const current = this.props.fieldValue;
  133. let fieldValue: QueryFieldValue = cloneDeep(this.props.fieldValue);
  134. switch (value.kind) {
  135. case FieldValueKind.TAG:
  136. case FieldValueKind.MEASUREMENT:
  137. case FieldValueKind.CUSTOM_MEASUREMENT:
  138. case FieldValueKind.BREAKDOWN:
  139. case FieldValueKind.FIELD:
  140. fieldValue = {kind: 'field', field: value.meta.name};
  141. break;
  142. case FieldValueKind.NUMERIC_METRICS:
  143. fieldValue = {
  144. kind: 'calculatedField',
  145. field: value.meta.name,
  146. };
  147. break;
  148. case FieldValueKind.FUNCTION:
  149. if (current.kind === 'function') {
  150. fieldValue = {
  151. kind: 'function',
  152. function: [
  153. value.meta.name as AggregationKey,
  154. current.function[1],
  155. current.function[2],
  156. current.function[3],
  157. ],
  158. };
  159. } else {
  160. fieldValue = {
  161. kind: 'function',
  162. function: [value.meta.name as AggregationKey, '', undefined, undefined],
  163. };
  164. }
  165. break;
  166. case FieldValueKind.EQUATION:
  167. fieldValue = {
  168. kind: 'equation',
  169. field: value.meta.name,
  170. alias: value.meta.name,
  171. };
  172. break;
  173. default:
  174. throw new Error('Invalid field type found in column picker');
  175. }
  176. if (value.kind === FieldValueKind.FUNCTION) {
  177. value.meta.parameters.forEach((param: AggregateParameter, i: number) => {
  178. if (fieldValue.kind !== 'function') {
  179. return;
  180. }
  181. if (param.kind === 'column') {
  182. const field = this.getFieldOrTagOrMeasurementValue(fieldValue.function[i + 1]);
  183. if (field === null) {
  184. fieldValue.function[i + 1] = param.defaultValue || '';
  185. } else if (
  186. (field.kind === FieldValueKind.FIELD ||
  187. field.kind === FieldValueKind.TAG ||
  188. field.kind === FieldValueKind.MEASUREMENT ||
  189. field.kind === FieldValueKind.CUSTOM_MEASUREMENT ||
  190. field.kind === FieldValueKind.METRICS ||
  191. field.kind === FieldValueKind.BREAKDOWN) &&
  192. validateColumnTypes(param.columnTypes as ValidateColumnTypes, field)
  193. ) {
  194. // New function accepts current field.
  195. fieldValue.function[i + 1] = field.meta.name;
  196. } else {
  197. // field does not fit within new function requirements, use the default.
  198. fieldValue.function[i + 1] = param.defaultValue || '';
  199. fieldValue.function[i + 2] = undefined;
  200. fieldValue.function[i + 3] = undefined;
  201. }
  202. } else {
  203. fieldValue.function[i + 1] = param.defaultValue || '';
  204. }
  205. });
  206. if (fieldValue.kind === 'function') {
  207. if (value.meta.parameters.length === 0) {
  208. fieldValue.function = [fieldValue.function[0], '', undefined, undefined];
  209. } else if (value.meta.parameters.length === 1) {
  210. fieldValue.function[2] = undefined;
  211. fieldValue.function[3] = undefined;
  212. } else if (value.meta.parameters.length === 2) {
  213. fieldValue.function[3] = undefined;
  214. }
  215. }
  216. }
  217. this.triggerChange(fieldValue);
  218. };
  219. handleEquationChange = (value: string) => {
  220. const newColumn = cloneDeep(this.props.fieldValue);
  221. if (newColumn.kind === FieldValueKind.EQUATION) {
  222. newColumn.field = value;
  223. }
  224. this.triggerChange(newColumn);
  225. };
  226. handleFieldParameterChange = ({value}) => {
  227. const newColumn = cloneDeep(this.props.fieldValue);
  228. if (newColumn.kind === 'function') {
  229. newColumn.function[1] = value.meta.name;
  230. }
  231. this.triggerChange(newColumn);
  232. };
  233. handleDropdownParameterChange = (index: number) => {
  234. return (value: SelectValue<string>) => {
  235. const newColumn = cloneDeep(this.props.fieldValue);
  236. if (newColumn.kind === 'function') {
  237. newColumn.function[index] = value.value;
  238. }
  239. this.triggerChange(newColumn);
  240. };
  241. };
  242. handleScalarParameterChange = (index: number) => {
  243. return (value: string) => {
  244. const newColumn = cloneDeep(this.props.fieldValue);
  245. if (newColumn.kind === 'function') {
  246. newColumn.function[index] = value;
  247. }
  248. this.triggerChange(newColumn);
  249. };
  250. };
  251. triggerChange(fieldValue: QueryFieldValue) {
  252. this.props.onChange(fieldValue);
  253. }
  254. getFieldOrTagOrMeasurementValue(name: string | undefined): FieldValue | null {
  255. const {fieldOptions} = this.props;
  256. if (name === undefined) {
  257. return null;
  258. }
  259. const fieldName = `field:${name}`;
  260. if (fieldOptions[fieldName]) {
  261. return fieldOptions[fieldName].value;
  262. }
  263. const measurementName = `measurement:${name}`;
  264. if (fieldOptions[measurementName]) {
  265. return fieldOptions[measurementName].value;
  266. }
  267. const spanOperationBreakdownName = `span_op_breakdown:${name}`;
  268. if (fieldOptions[spanOperationBreakdownName]) {
  269. return fieldOptions[spanOperationBreakdownName].value;
  270. }
  271. const equationName = `equation:${name}`;
  272. if (fieldOptions[equationName]) {
  273. return fieldOptions[equationName].value;
  274. }
  275. const tagName =
  276. name.indexOf('tags[') === 0
  277. ? `tag:${name.replace(/tags\[(.*?)\]/, '$1')}`
  278. : `tag:${name}`;
  279. if (fieldOptions[tagName]) {
  280. return fieldOptions[tagName].value;
  281. }
  282. // Likely a tag that was deleted but left behind in a saved query
  283. // Cook up a tag option so select control works.
  284. if (name.length > 0) {
  285. return {
  286. kind: FieldValueKind.TAG,
  287. meta: {
  288. name,
  289. dataType: 'string',
  290. unknown: true,
  291. },
  292. };
  293. }
  294. return null;
  295. }
  296. getFieldData() {
  297. let field: FieldValue | null = null;
  298. const {fieldValue} = this.props;
  299. let {fieldOptions} = this.props;
  300. if (fieldValue?.kind === 'function') {
  301. const funcName = `function:${fieldValue.function[0]}`;
  302. if (fieldOptions[funcName] !== undefined) {
  303. field = fieldOptions[funcName].value;
  304. }
  305. }
  306. if (fieldValue?.kind === 'field' || fieldValue?.kind === 'calculatedField') {
  307. field = this.getFieldOrTagOrMeasurementValue(fieldValue.field);
  308. fieldOptions = this.appendFieldIfUnknown(fieldOptions, field);
  309. }
  310. let parameterDescriptions: ParameterDescription[] = [];
  311. // Generate options and values for each parameter.
  312. if (
  313. field &&
  314. field.kind === FieldValueKind.FUNCTION &&
  315. field.meta.parameters.length > 0 &&
  316. fieldValue?.kind === FieldValueKind.FUNCTION
  317. ) {
  318. parameterDescriptions = field.meta.parameters.map(
  319. (param, index: number): ParameterDescription => {
  320. if (param.kind === 'column') {
  321. const fieldParameter = this.getFieldOrTagOrMeasurementValue(
  322. fieldValue.function[1]
  323. );
  324. fieldOptions = this.appendFieldIfUnknown(fieldOptions, fieldParameter);
  325. return {
  326. kind: 'column',
  327. value: fieldParameter,
  328. required: param.required,
  329. options: Object.values(fieldOptions).filter(
  330. ({value}) =>
  331. (value.kind === FieldValueKind.FIELD ||
  332. value.kind === FieldValueKind.TAG ||
  333. value.kind === FieldValueKind.MEASUREMENT ||
  334. value.kind === FieldValueKind.CUSTOM_MEASUREMENT ||
  335. value.kind === FieldValueKind.METRICS ||
  336. value.kind === FieldValueKind.BREAKDOWN) &&
  337. validateColumnTypes(param.columnTypes as ValidateColumnTypes, value)
  338. ),
  339. };
  340. }
  341. if (param.kind === 'dropdown') {
  342. return {
  343. kind: 'dropdown',
  344. options: param.options,
  345. dataType: param.dataType,
  346. required: param.required,
  347. value:
  348. (fieldValue.kind === 'function' && fieldValue.function[index + 1]) ||
  349. param.defaultValue ||
  350. '',
  351. };
  352. }
  353. return {
  354. kind: 'value',
  355. value:
  356. (fieldValue.kind === 'function' && fieldValue.function[index + 1]) ||
  357. param.defaultValue ||
  358. '',
  359. dataType: param.dataType,
  360. required: param.required,
  361. placeholder: param.placeholder,
  362. };
  363. }
  364. );
  365. }
  366. return {field, fieldOptions, parameterDescriptions};
  367. }
  368. appendFieldIfUnknown(
  369. fieldOptions: FieldOptions,
  370. field: FieldValue | null
  371. ): FieldOptions {
  372. if (!field) {
  373. return fieldOptions;
  374. }
  375. if (field && field.kind === FieldValueKind.TAG && field.meta.unknown) {
  376. // Clone the options so we don't mutate other rows.
  377. fieldOptions = Object.assign({}, fieldOptions);
  378. fieldOptions[field.meta.name] = {label: field.meta.name, value: field};
  379. }
  380. return fieldOptions;
  381. }
  382. renderParameterInputs(parameters: ParameterDescription[]): React.ReactNode[] {
  383. const {
  384. disabled,
  385. inFieldLabels,
  386. filterAggregateParameters,
  387. hideParameterSelector,
  388. skipParameterPlaceholder,
  389. fieldValue,
  390. } = this.props;
  391. const inputs = parameters.map((descriptor: ParameterDescription, index: number) => {
  392. if (descriptor.kind === 'column' && descriptor.options.length > 0) {
  393. if (hideParameterSelector) {
  394. return null;
  395. }
  396. const aggregateParameters = filterAggregateParameters
  397. ? descriptor.options.filter(option =>
  398. filterAggregateParameters(option, fieldValue)
  399. )
  400. : descriptor.options;
  401. aggregateParameters.forEach(opt => {
  402. opt.trailingItems = this.renderTag(opt.value.kind, String(opt.label));
  403. });
  404. return (
  405. <SelectControl
  406. key="select"
  407. name="parameter"
  408. menuPlacement="auto"
  409. placeholder={t('Select value')}
  410. options={aggregateParameters}
  411. value={descriptor.value}
  412. required={descriptor.required}
  413. onChange={this.handleFieldParameterChange}
  414. inFieldLabel={inFieldLabels ? t('Parameter: ') : undefined}
  415. disabled={disabled}
  416. styles={!inFieldLabels ? this.FieldSelectStyles : undefined}
  417. components={this.FieldSelectComponents}
  418. />
  419. );
  420. }
  421. if (descriptor.kind === 'value') {
  422. const inputProps = {
  423. required: descriptor.required,
  424. value: descriptor.value,
  425. onUpdate: this.handleScalarParameterChange(index + 1),
  426. placeholder: descriptor.placeholder,
  427. disabled,
  428. };
  429. switch (descriptor.dataType) {
  430. case 'number':
  431. return (
  432. <BufferedInput
  433. name="refinement"
  434. key="parameter:number"
  435. type="text"
  436. inputMode="numeric"
  437. pattern="[0-9]*(\.[0-9]*)?"
  438. {...inputProps}
  439. />
  440. );
  441. case 'integer':
  442. return (
  443. <BufferedInput
  444. name="refinement"
  445. key="parameter:integer"
  446. type="text"
  447. inputMode="numeric"
  448. pattern="[0-9]*"
  449. {...inputProps}
  450. />
  451. );
  452. default:
  453. return (
  454. <BufferedInput
  455. name="refinement"
  456. key="parameter:text"
  457. type="text"
  458. {...inputProps}
  459. />
  460. );
  461. }
  462. }
  463. if (descriptor.kind === 'dropdown') {
  464. return (
  465. <SelectControl
  466. key="dropdown"
  467. name="dropdown"
  468. menuPlacement="auto"
  469. placeholder={t('Select value')}
  470. options={descriptor.options}
  471. value={descriptor.value}
  472. required={descriptor.required}
  473. onChange={this.handleDropdownParameterChange(index + 1)}
  474. inFieldLabel={inFieldLabels ? t('Parameter: ') : undefined}
  475. disabled={disabled}
  476. />
  477. );
  478. }
  479. throw new Error(`Unknown parameter type encountered for ${this.props.fieldValue}`);
  480. });
  481. if (skipParameterPlaceholder) {
  482. return inputs;
  483. }
  484. // Add enough disabled inputs to fill the grid up.
  485. // We always have 1 input.
  486. const {gridColumns} = this.props;
  487. const requiredInputs = (gridColumns ?? inputs.length + 1) - inputs.length - 1;
  488. if (gridColumns !== undefined && requiredInputs > 0) {
  489. for (let i = 0; i < requiredInputs; i++) {
  490. inputs.push(<BlankSpace key={i} />);
  491. }
  492. }
  493. return inputs;
  494. }
  495. renderTag(kind: FieldValueKind, label: string) {
  496. const {shouldRenderTag} = this.props;
  497. if (shouldRenderTag === false) {
  498. return null;
  499. }
  500. let text, tagType;
  501. switch (kind) {
  502. case FieldValueKind.FUNCTION:
  503. text = 'f(x)';
  504. tagType = 'success';
  505. break;
  506. case FieldValueKind.CUSTOM_MEASUREMENT:
  507. case FieldValueKind.MEASUREMENT:
  508. text = 'field';
  509. tagType = 'highlight';
  510. break;
  511. case FieldValueKind.BREAKDOWN:
  512. text = 'field';
  513. tagType = 'highlight';
  514. break;
  515. case FieldValueKind.TAG:
  516. text = kind;
  517. tagType = 'warning';
  518. break;
  519. case FieldValueKind.NUMERIC_METRICS:
  520. text = 'f(x)';
  521. tagType = 'success';
  522. break;
  523. case FieldValueKind.FIELD:
  524. case FieldValueKind.METRICS:
  525. text = DEPRECATED_FIELDS.includes(label) ? 'deprecated' : 'field';
  526. tagType = 'highlight';
  527. break;
  528. default:
  529. text = kind;
  530. }
  531. return <Tag type={tagType}>{text}</Tag>;
  532. }
  533. render() {
  534. const {
  535. className,
  536. takeFocus,
  537. filterPrimaryOptions,
  538. fieldValue,
  539. inFieldLabels,
  540. disabled,
  541. error,
  542. hidePrimarySelector,
  543. gridColumns,
  544. otherColumns,
  545. placeholder,
  546. noFieldsMessage,
  547. skipParameterPlaceholder,
  548. } = this.props;
  549. const {field, fieldOptions, parameterDescriptions} = this.getFieldData();
  550. const allFieldOptions = filterPrimaryOptions
  551. ? Object.values(fieldOptions).filter(filterPrimaryOptions)
  552. : Object.values(fieldOptions);
  553. allFieldOptions.forEach(opt => {
  554. opt.trailingItems = this.renderTag(opt.value.kind, String(opt.label));
  555. });
  556. const selectProps: ControlProps<FieldValueOption> = {
  557. name: 'field',
  558. options: Object.values(allFieldOptions),
  559. placeholder: placeholder ?? t('(Required)'),
  560. value: field,
  561. onChange: this.handleFieldChange,
  562. inFieldLabel: inFieldLabels ? t('Function: ') : undefined,
  563. disabled,
  564. noOptionsMessage: () => noFieldsMessage,
  565. menuPlacement: 'auto',
  566. };
  567. if (takeFocus && field === null) {
  568. selectProps.autoFocus = true;
  569. }
  570. const parameters = this.renderParameterInputs(parameterDescriptions);
  571. if (fieldValue?.kind === FieldValueKind.EQUATION) {
  572. return (
  573. <Container
  574. className={className}
  575. gridColumns={1}
  576. tripleLayout={false}
  577. error={error !== undefined}
  578. >
  579. <ArithmeticInput
  580. name="arithmetic"
  581. key="parameter:text"
  582. type="text"
  583. required
  584. value={fieldValue.field}
  585. onUpdate={this.handleEquationChange}
  586. options={otherColumns}
  587. placeholder={t('Equation')}
  588. />
  589. {error ? (
  590. <ArithmeticError title={error}>
  591. <IconWarning color="red300" />
  592. </ArithmeticError>
  593. ) : null}
  594. </Container>
  595. );
  596. }
  597. // if there's more than 2 parameters, set gridColumns to 2 so they go onto the next line instead
  598. const containerColumns =
  599. parameters.length > 2 ? 2 : gridColumns ? gridColumns : parameters.length + 1;
  600. let gridColumnsQuantity: undefined | number = undefined;
  601. if (skipParameterPlaceholder) {
  602. // if the selected field is a function and has parameters, we would like to display each value in separate columns.
  603. // Otherwise the field should be displayed in a column, taking up all available space and not displaying the "no parameter" field
  604. if (fieldValue.kind !== 'function') {
  605. gridColumnsQuantity = 1;
  606. } else {
  607. const operation =
  608. AGGREGATIONS[fieldValue.function[0]] ??
  609. SESSIONS_OPERATIONS[fieldValue.function[0]];
  610. if (operation.parameters.length > 0) {
  611. if (containerColumns === 3 && operation.parameters.length === 1) {
  612. gridColumnsQuantity = 2;
  613. } else {
  614. gridColumnsQuantity = containerColumns;
  615. }
  616. } else {
  617. gridColumnsQuantity = 1;
  618. }
  619. }
  620. }
  621. return (
  622. <Container
  623. className={className}
  624. gridColumns={gridColumnsQuantity ?? containerColumns}
  625. tripleLayout={gridColumns === 3 && parameters.length > 2}
  626. >
  627. {!hidePrimarySelector && (
  628. <SelectControl
  629. {...selectProps}
  630. styles={!inFieldLabels ? this.FieldSelectStyles : undefined}
  631. components={this.FieldSelectComponents}
  632. />
  633. )}
  634. {parameters}
  635. </Container>
  636. );
  637. }
  638. }
  639. function validateColumnTypes(
  640. columnTypes: ValidateColumnTypes,
  641. input: FieldValueColumns
  642. ): boolean {
  643. if (typeof columnTypes === 'function') {
  644. return columnTypes({name: input.meta.name, dataType: input.meta.dataType});
  645. }
  646. return (columnTypes as string[]).includes(input.meta.dataType);
  647. }
  648. const Container = styled('div')<{
  649. gridColumns: number;
  650. tripleLayout: boolean;
  651. error?: boolean;
  652. }>`
  653. display: grid;
  654. ${p =>
  655. p.tripleLayout
  656. ? `grid-template-columns: 1fr 2fr;`
  657. : `grid-template-columns: repeat(${p.gridColumns}, 1fr) ${p.error ? 'auto' : ''};`}
  658. gap: ${space(1)};
  659. align-items: center;
  660. flex-grow: 1;
  661. `;
  662. interface BufferedInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  663. onUpdate: (value: string) => void;
  664. value: string;
  665. }
  666. type InputState = {value: string};
  667. /**
  668. * Because controlled inputs fire onChange on every key stroke,
  669. * we can't update the QueryField that often as it would re-render
  670. * the input elements causing focus to be lost.
  671. *
  672. * Using a buffered input lets us throttle rendering and enforce data
  673. * constraints better.
  674. */
  675. class BufferedInput extends Component<BufferedInputProps, InputState> {
  676. constructor(props: BufferedInputProps) {
  677. super(props);
  678. this.input = createRef();
  679. }
  680. state: InputState = {
  681. value: this.props.value,
  682. };
  683. private input: React.RefObject<HTMLInputElement>;
  684. get isValid() {
  685. if (!this.input.current) {
  686. return true;
  687. }
  688. return this.input.current.validity.valid;
  689. }
  690. handleBlur = () => {
  691. if (this.props.required && this.state.value === '') {
  692. // Handle empty strings separately because we don't pass required
  693. // to input elements, causing isValid to return true
  694. this.setState({value: this.props.value});
  695. } else if (this.isValid) {
  696. this.props.onUpdate(this.state.value);
  697. } else {
  698. this.setState({value: this.props.value});
  699. }
  700. };
  701. handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
  702. if (this.isValid) {
  703. this.setState({value: event.target.value});
  704. }
  705. };
  706. render() {
  707. const {onUpdate: _, ...props} = this.props;
  708. return (
  709. <StyledInput
  710. {...props}
  711. ref={this.input}
  712. className="form-control"
  713. value={this.state.value}
  714. onChange={this.handleChange}
  715. onBlur={this.handleBlur}
  716. />
  717. );
  718. }
  719. }
  720. // Set a min-width to allow shrinkage in grid.
  721. const StyledInput = styled(Input)<InputProps>`
  722. /* Match the height of the select boxes */
  723. height: 41px;
  724. min-width: 50px;
  725. `;
  726. const BlankSpace = styled('div')`
  727. /* Match the height of the select boxes */
  728. height: 41px;
  729. min-width: 50px;
  730. background: ${p => p.theme.backgroundSecondary};
  731. border-radius: ${p => p.theme.borderRadius};
  732. display: flex;
  733. align-items: center;
  734. justify-content: center;
  735. &:after {
  736. font-size: ${p => p.theme.fontSizeMedium};
  737. content: '${t('No parameter')}';
  738. color: ${p => p.theme.gray300};
  739. }
  740. `;
  741. const ArithmeticError = styled(Tooltip)`
  742. color: ${p => p.theme.red300};
  743. animation: ${() => pulse(1.15)} 1s ease infinite;
  744. display: flex;
  745. `;
  746. export {QueryField};