queryField.tsx 20 KB

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