queryField.tsx 19 KB

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