Files
2024-02-15 17:01:50 +00:00

846 lines
25 KiB
TypeScript

import {Component, createRef} from 'react';
import type {SingleValueProps} from 'react-select';
import {components} from 'react-select';
import styled from '@emotion/styled';
import cloneDeep from 'lodash/cloneDeep';
import type {ControlProps} from 'sentry/components/forms/controls/selectControl';
import SelectControl from 'sentry/components/forms/controls/selectControl';
import type {InputProps} from 'sentry/components/input';
import Input from 'sentry/components/input';
import Tag from 'sentry/components/tag';
import {Tooltip} from 'sentry/components/tooltip';
import {IconWarning} from 'sentry/icons';
import {t} from 'sentry/locale';
import {pulse} from 'sentry/styles/animations';
import {space} from 'sentry/styles/space';
import type {SelectValue} from 'sentry/types';
import type {
AggregateParameter,
AggregationKeyWithAlias,
Column,
ColumnType,
QueryFieldValue,
ValidateColumnTypes,
} from 'sentry/utils/discover/fields';
import {AGGREGATIONS, DEPRECATED_FIELDS} from 'sentry/utils/discover/fields';
import {SESSIONS_OPERATIONS} from 'sentry/views/dashboards/widgetBuilder/releaseWidget/fields';
import ArithmeticInput from './arithmeticInput';
import type {FieldValue, FieldValueColumns} from './types';
import {FieldValueKind} from './types';
export type FieldValueOption = SelectValue<FieldValue>;
type FieldOptions = Record<string, FieldValueOption>;
// Intermediate type that combines the current column
// data with the AggregateParameter type.
type ParameterDescription =
| {
dataType: ColumnType;
kind: 'value';
required: boolean;
value: string;
placeholder?: string;
}
| {
kind: 'column';
options: FieldValueOption[];
required: boolean;
value: FieldValue | null;
}
| {
dataType: string;
kind: 'dropdown';
options: SelectValue<string>[];
required: boolean;
value: string;
placeholder?: string;
};
type Props = {
fieldOptions: FieldOptions;
fieldValue: QueryFieldValue;
onChange: (fieldValue: QueryFieldValue) => void;
className?: string;
disabled?: boolean;
error?: string;
/**
* Function to filter the options that are used as parameters for function/aggregate.
*/
filterAggregateParameters?: (
option: FieldValueOption,
fieldValue?: QueryFieldValue
) => boolean;
/**
* Filter the options in the primary selector. Useful if you only want to
* show a subset of selectable items.
*
* NOTE: This is different from passing an already filtered fieldOptions
* list, as tag items in the list may be used as parameters to functions.
*/
filterPrimaryOptions?: (option: FieldValueOption) => boolean;
/**
* The number of columns to render. Columns that do not have a parameter will
* render an empty parameter placeholder. Leave blank to avoid adding spacers.
*/
gridColumns?: number;
hideParameterSelector?: boolean;
hidePrimarySelector?: boolean;
/**
* Whether or not to add labels inside of the input fields, currently only
* used for the metric alert builder.
*/
inFieldLabels?: boolean;
/**
* This will be displayed in the select if there are no fields
*/
noFieldsMessage?: string;
otherColumns?: Column[];
placeholder?: string;
/**
* Whether or not to add the tag explaining the FieldValueKind of each field
*/
shouldRenderTag?: boolean;
skipParameterPlaceholder?: boolean;
takeFocus?: boolean;
};
// Type for completing generics in react-select
type OptionType = {
label: string;
value: FieldValue;
};
class QueryField extends Component<Props> {
FieldSelectComponents = {
SingleValue: ({data, ...props}: SingleValueProps<OptionType>) => {
return (
<components.SingleValue data={data} {...props}>
<span data-test-id="label">{data.label}</span>
{data.value && this.renderTag(data.value.kind, data.label)}
</components.SingleValue>
);
},
};
FieldSelectStyles = {
singleValue(provided: React.CSSProperties) {
const custom = {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
};
return {...provided, ...custom};
},
};
handleFieldChange = (selected?: FieldValueOption | null) => {
if (!selected) {
return;
}
const {value} = selected;
const current = this.props.fieldValue;
let fieldValue: QueryFieldValue = cloneDeep(this.props.fieldValue);
switch (value.kind) {
case FieldValueKind.TAG:
case FieldValueKind.MEASUREMENT:
case FieldValueKind.CUSTOM_MEASUREMENT:
case FieldValueKind.BREAKDOWN:
case FieldValueKind.FIELD:
fieldValue = {kind: 'field', field: value.meta.name};
break;
case FieldValueKind.NUMERIC_METRICS:
fieldValue = {
kind: 'calculatedField',
field: value.meta.name,
};
break;
case FieldValueKind.FUNCTION:
if (current.kind === 'function') {
fieldValue = {
kind: 'function',
function: [
value.meta.name as AggregationKeyWithAlias,
current.function[1],
current.function[2],
current.function[3],
],
};
} else {
fieldValue = {
kind: 'function',
function: [
value.meta.name as AggregationKeyWithAlias,
'',
undefined,
undefined,
],
};
}
break;
case FieldValueKind.EQUATION:
fieldValue = {
kind: 'equation',
field: value.meta.name,
alias: value.meta.name,
};
break;
default:
throw new Error('Invalid field type found in column picker');
}
if (value.kind === FieldValueKind.FUNCTION) {
value.meta.parameters.forEach((param: AggregateParameter, i: number) => {
if (fieldValue.kind !== 'function') {
return;
}
if (param.kind === 'column') {
const field = this.getFieldOrTagOrMeasurementValue(fieldValue.function[i + 1]);
if (field === null) {
fieldValue.function[i + 1] = param.defaultValue || '';
} else if (
(field.kind === FieldValueKind.FIELD ||
field.kind === FieldValueKind.TAG ||
field.kind === FieldValueKind.MEASUREMENT ||
field.kind === FieldValueKind.CUSTOM_MEASUREMENT ||
field.kind === FieldValueKind.METRICS ||
field.kind === FieldValueKind.BREAKDOWN) &&
validateColumnTypes(param.columnTypes as ValidateColumnTypes, field)
) {
// New function accepts current field.
fieldValue.function[i + 1] = field.meta.name;
} else {
// field does not fit within new function requirements, use the default.
fieldValue.function[i + 1] = param.defaultValue || '';
fieldValue.function[i + 2] = undefined;
fieldValue.function[i + 3] = undefined;
}
} else {
fieldValue.function[i + 1] = param.defaultValue || '';
}
});
if (fieldValue.kind === 'function') {
if (value.meta.parameters.length === 0) {
fieldValue.function = [fieldValue.function[0], '', undefined, undefined];
} else if (value.meta.parameters.length === 1) {
fieldValue.function[2] = undefined;
fieldValue.function[3] = undefined;
} else if (value.meta.parameters.length === 2) {
fieldValue.function[3] = undefined;
}
}
}
this.triggerChange(fieldValue);
};
handleEquationChange = (value: string) => {
const newColumn = cloneDeep(this.props.fieldValue);
if (newColumn.kind === FieldValueKind.EQUATION) {
newColumn.field = value;
}
this.triggerChange(newColumn);
};
handleFieldParameterChange = ({value}) => {
const newColumn = cloneDeep(this.props.fieldValue);
if (newColumn.kind === 'function') {
newColumn.function[1] = value.meta.name;
}
this.triggerChange(newColumn);
};
handleDropdownParameterChange = (index: number) => {
return (value: SelectValue<string>) => {
const newColumn = cloneDeep(this.props.fieldValue);
if (newColumn.kind === 'function') {
newColumn.function[index] = value.value;
}
this.triggerChange(newColumn);
};
};
handleScalarParameterChange = (index: number) => {
return (value: string) => {
const newColumn = cloneDeep(this.props.fieldValue);
if (newColumn.kind === 'function') {
newColumn.function[index] = value;
}
this.triggerChange(newColumn);
};
};
triggerChange(fieldValue: QueryFieldValue) {
this.props.onChange(fieldValue);
}
getFieldOrTagOrMeasurementValue(
name: string | undefined,
functions: string[] = []
): FieldValue | null {
const {fieldOptions} = this.props;
if (name === undefined) {
return null;
}
const fieldName = `field:${name}`;
if (fieldOptions[fieldName]) {
return fieldOptions[fieldName].value;
}
const measurementName = `measurement:${name}`;
if (fieldOptions[measurementName]) {
return fieldOptions[measurementName].value;
}
const spanOperationBreakdownName = `span_op_breakdown:${name}`;
if (fieldOptions[spanOperationBreakdownName]) {
return fieldOptions[spanOperationBreakdownName].value;
}
const equationName = `equation:${name}`;
if (fieldOptions[equationName]) {
return fieldOptions[equationName].value;
}
const tagName =
name.indexOf('tags[') === 0
? `tag:${name.replace(/tags\[(.*?)\]/, '$1')}`
: `tag:${name}`;
if (fieldOptions[tagName]) {
return fieldOptions[tagName].value;
}
if (name.length > 0) {
// Custom Measurement. Probably not appearing in field options because
// no metrics found within selected time range
if (name.startsWith('measurements.')) {
return {
kind: FieldValueKind.CUSTOM_MEASUREMENT,
meta: {
name,
dataType: 'number',
functions,
},
};
}
// Likely a tag that was deleted but left behind in a saved query
// Cook up a tag option so select control works.
return {
kind: FieldValueKind.TAG,
meta: {
name,
dataType: 'string',
unknown: true,
},
};
}
return null;
}
getFieldData() {
let field: FieldValue | null = null;
const {fieldValue} = this.props;
let {fieldOptions} = this.props;
if (fieldValue?.kind === 'function') {
const funcName = `function:${fieldValue.function[0]}`;
if (fieldOptions[funcName] !== undefined) {
field = fieldOptions[funcName].value;
}
}
if (fieldValue?.kind === 'field' || fieldValue?.kind === 'calculatedField') {
field = this.getFieldOrTagOrMeasurementValue(fieldValue.field);
fieldOptions = this.appendFieldIfUnknown(fieldOptions, field);
}
let parameterDescriptions: ParameterDescription[] = [];
// Generate options and values for each parameter.
if (
field &&
field.kind === FieldValueKind.FUNCTION &&
field.meta.parameters.length > 0 &&
fieldValue?.kind === FieldValueKind.FUNCTION
) {
parameterDescriptions = field.meta.parameters.map(
(param, index: number): ParameterDescription => {
if (param.kind === 'column') {
const fieldParameter = this.getFieldOrTagOrMeasurementValue(
fieldValue.function[1],
[fieldValue.function[0]]
);
fieldOptions = this.appendFieldIfUnknown(fieldOptions, fieldParameter);
return {
kind: 'column',
value: fieldParameter,
required: param.required,
options: Object.values(fieldOptions).filter(
({value}) =>
(value.kind === FieldValueKind.FIELD ||
value.kind === FieldValueKind.TAG ||
value.kind === FieldValueKind.MEASUREMENT ||
value.kind === FieldValueKind.CUSTOM_MEASUREMENT ||
value.kind === FieldValueKind.METRICS ||
value.kind === FieldValueKind.BREAKDOWN) &&
validateColumnTypes(param.columnTypes as ValidateColumnTypes, value)
),
};
}
if (param.kind === 'dropdown') {
return {
kind: 'dropdown',
options: param.options,
dataType: param.dataType,
required: param.required,
value:
(fieldValue.kind === 'function' && fieldValue.function[index + 1]) ||
param.defaultValue ||
'',
};
}
return {
kind: 'value',
value:
(fieldValue.kind === 'function' && fieldValue.function[index + 1]) ||
param.defaultValue ||
'',
dataType: param.dataType,
required: param.required,
placeholder: param.placeholder,
};
}
);
}
return {field, fieldOptions, parameterDescriptions};
}
appendFieldIfUnknown(
fieldOptions: FieldOptions,
field: FieldValue | null
): FieldOptions {
if (!field) {
return fieldOptions;
}
if (field && field.kind === FieldValueKind.TAG && field.meta.unknown) {
// Clone the options so we don't mutate other rows.
fieldOptions = Object.assign({}, fieldOptions);
fieldOptions[field.meta.name] = {label: field.meta.name, value: field};
} else if (field && field.kind === FieldValueKind.CUSTOM_MEASUREMENT) {
fieldOptions = Object.assign({}, fieldOptions);
fieldOptions[`measurement:${field.meta.name}`] = {
label: field.meta.name,
value: field,
};
}
return fieldOptions;
}
renderParameterInputs(parameters: ParameterDescription[]): React.ReactNode[] {
const {
disabled,
inFieldLabels,
filterAggregateParameters,
hideParameterSelector,
skipParameterPlaceholder,
fieldValue,
} = this.props;
const inputs = parameters.map((descriptor: ParameterDescription, index: number) => {
if (descriptor.kind === 'column' && descriptor.options.length > 0) {
if (hideParameterSelector) {
return null;
}
const aggregateParameters = filterAggregateParameters
? descriptor.options.filter(option =>
filterAggregateParameters(option, fieldValue)
)
: descriptor.options;
aggregateParameters.forEach(opt => {
opt.trailingItems = this.renderTag(opt.value.kind, String(opt.label));
});
return (
<SelectControl
key="select"
name="parameter"
menuPlacement="auto"
placeholder={t('Select value')}
options={aggregateParameters}
value={descriptor.value}
required={descriptor.required}
onChange={this.handleFieldParameterChange}
inFieldLabel={inFieldLabels ? t('Parameter: ') : undefined}
disabled={disabled}
styles={!inFieldLabels ? this.FieldSelectStyles : undefined}
components={this.FieldSelectComponents}
/>
);
}
if (descriptor.kind === 'value') {
const inputProps = {
required: descriptor.required,
value: descriptor.value,
onUpdate: this.handleScalarParameterChange(index + 1),
placeholder: descriptor.placeholder,
disabled,
};
switch (descriptor.dataType) {
case 'number':
return (
<BufferedInput
name="refinement"
key="parameter:number"
type="text"
inputMode="numeric"
pattern="[0-9]*(\.[0-9]*)?"
{...inputProps}
/>
);
case 'integer':
return (
<BufferedInput
name="refinement"
key="parameter:integer"
type="text"
inputMode="numeric"
pattern="[0-9]*"
{...inputProps}
/>
);
default:
return (
<BufferedInput
name="refinement"
key="parameter:text"
type="text"
{...inputProps}
/>
);
}
}
if (descriptor.kind === 'dropdown') {
return (
<SelectControl
key="dropdown"
name="dropdown"
menuPlacement="auto"
placeholder={t('Select value')}
options={descriptor.options}
value={descriptor.value}
required={descriptor.required}
onChange={this.handleDropdownParameterChange(index + 1)}
inFieldLabel={inFieldLabels ? t('Parameter: ') : undefined}
disabled={disabled}
/>
);
}
throw new Error(`Unknown parameter type encountered for ${this.props.fieldValue}`);
});
if (skipParameterPlaceholder) {
return inputs;
}
// Add enough disabled inputs to fill the grid up.
// We always have 1 input.
const {gridColumns} = this.props;
const requiredInputs = (gridColumns ?? inputs.length + 1) - inputs.length - 1;
if (gridColumns !== undefined && requiredInputs > 0) {
for (let i = 0; i < requiredInputs; i++) {
inputs.push(<BlankSpace key={i} data-test-id="blankSpace" />);
}
}
return inputs;
}
renderTag(kind: FieldValueKind, label: string) {
const {shouldRenderTag} = this.props;
if (shouldRenderTag === false) {
return null;
}
let text, tagType;
switch (kind) {
case FieldValueKind.FUNCTION:
text = 'f(x)';
tagType = 'success';
break;
case FieldValueKind.CUSTOM_MEASUREMENT:
case FieldValueKind.MEASUREMENT:
text = 'field';
tagType = 'highlight';
break;
case FieldValueKind.BREAKDOWN:
text = 'field';
tagType = 'highlight';
break;
case FieldValueKind.TAG:
text = kind;
tagType = 'warning';
break;
case FieldValueKind.NUMERIC_METRICS:
text = 'f(x)';
tagType = 'success';
break;
case FieldValueKind.FIELD:
text = DEPRECATED_FIELDS.includes(label) ? 'deprecated' : 'field';
tagType = 'highlight';
break;
default:
text = kind;
}
return <Tag type={tagType}>{text}</Tag>;
}
render() {
const {
className,
takeFocus,
filterPrimaryOptions,
fieldValue,
inFieldLabels,
disabled,
error,
hidePrimarySelector,
gridColumns,
otherColumns,
placeholder,
noFieldsMessage,
skipParameterPlaceholder,
} = this.props;
const {field, fieldOptions, parameterDescriptions} = this.getFieldData();
const allFieldOptions = filterPrimaryOptions
? Object.values(fieldOptions).filter(filterPrimaryOptions)
: Object.values(fieldOptions);
allFieldOptions.forEach(opt => {
opt.trailingItems = this.renderTag(opt.value.kind, String(opt.label));
});
const selectProps: ControlProps<FieldValueOption> = {
name: 'field',
options: Object.values(allFieldOptions),
placeholder: placeholder ?? t('(Required)'),
value: field,
onChange: this.handleFieldChange,
inFieldLabel: inFieldLabels ? t('Function: ') : undefined,
disabled,
noOptionsMessage: () => noFieldsMessage,
menuPlacement: 'auto',
};
if (takeFocus && field === null) {
selectProps.autoFocus = true;
}
const parameters = this.renderParameterInputs(parameterDescriptions);
if (fieldValue?.kind === FieldValueKind.EQUATION) {
return (
<Container
className={className}
gridColumns={1}
tripleLayout={false}
error={error !== undefined}
data-test-id="queryField"
>
<ArithmeticInput
name="arithmetic"
key="parameter:text"
type="text"
required
value={fieldValue.field}
onUpdate={this.handleEquationChange}
options={otherColumns}
placeholder={t('Equation')}
/>
{error ? (
<ArithmeticError title={error}>
<IconWarning color="errorText" data-test-id="arithmeticErrorWarning" />
</ArithmeticError>
) : null}
</Container>
);
}
// if there's more than 2 parameters, set gridColumns to 2 so they go onto the next line instead
const containerColumns =
parameters.length > 2 ? 2 : gridColumns ? gridColumns : parameters.length + 1;
let gridColumnsQuantity: undefined | number = undefined;
if (skipParameterPlaceholder) {
// if the selected field is a function and has parameters, we would like to display each value in separate columns.
// Otherwise the field should be displayed in a column, taking up all available space and not displaying the "no parameter" field
if (fieldValue.kind !== 'function') {
gridColumnsQuantity = 1;
} else {
const operation =
AGGREGATIONS[fieldValue.function[0]] ??
SESSIONS_OPERATIONS[fieldValue.function[0]];
if (operation?.parameters.length > 0) {
if (containerColumns === 3 && operation.parameters.length === 1) {
gridColumnsQuantity = 2;
} else {
gridColumnsQuantity = containerColumns;
}
} else {
gridColumnsQuantity = 1;
}
}
}
return (
<Container
className={className}
gridColumns={gridColumnsQuantity ?? containerColumns}
tripleLayout={gridColumns === 3 && parameters.length > 2}
data-test-id="queryField"
>
{!hidePrimarySelector && (
<SelectControl
{...selectProps}
styles={!inFieldLabels ? this.FieldSelectStyles : undefined}
components={this.FieldSelectComponents}
/>
)}
{parameters}
</Container>
);
}
}
function validateColumnTypes(
columnTypes: ValidateColumnTypes,
input: FieldValueColumns
): boolean {
if (typeof columnTypes === 'function') {
return columnTypes({name: input.meta.name, dataType: input.meta.dataType});
}
return (columnTypes as string[]).includes(input.meta.dataType);
}
const Container = styled('div')<{
gridColumns: number;
tripleLayout: boolean;
error?: boolean;
}>`
display: grid;
${p =>
p.tripleLayout
? `grid-template-columns: 1fr 2fr;`
: `grid-template-columns: repeat(${p.gridColumns}, 1fr) ${p.error ? 'auto' : ''};`}
gap: ${space(1)};
align-items: center;
flex-grow: 1;
`;
interface BufferedInputProps extends InputProps {
onUpdate: (value: string) => void;
value: string;
}
type InputState = {value: string};
/**
* Because controlled inputs fire onChange on every key stroke,
* we can't update the QueryField that often as it would re-render
* the input elements causing focus to be lost.
*
* Using a buffered input lets us throttle rendering and enforce data
* constraints better.
*/
class BufferedInput extends Component<BufferedInputProps, InputState> {
constructor(props: BufferedInputProps) {
super(props);
this.input = createRef();
}
state: InputState = {
value: this.props.value,
};
private input: React.RefObject<HTMLInputElement>;
get isValid() {
if (!this.input.current) {
return true;
}
return this.input.current.validity.valid;
}
handleBlur = () => {
if (this.props.required && this.state.value === '') {
// Handle empty strings separately because we don't pass required
// to input elements, causing isValid to return true
this.setState({value: this.props.value});
} else if (this.isValid) {
this.props.onUpdate(this.state.value);
} else {
this.setState({value: this.props.value});
}
};
handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (this.isValid) {
this.setState({value: event.target.value});
}
};
render() {
const {onUpdate: _, ...props} = this.props;
return (
<StyledInput
{...props}
ref={this.input}
className="form-control"
value={this.state.value}
onChange={this.handleChange}
onBlur={this.handleBlur}
/>
);
}
}
// Set a min-width to allow shrinkage in grid.
const StyledInput = styled(Input)`
min-width: 50px;
`;
const BlankSpace = styled('div')`
/* Match the height of the select boxes */
height: ${p => p.theme.form.md.height}px;
min-width: 50px;
background: ${p => p.theme.backgroundSecondary};
border-radius: ${p => p.theme.borderRadius};
display: flex;
align-items: center;
justify-content: center;
&:after {
font-size: ${p => p.theme.fontSizeMedium};
content: '${t('No parameter')}';
color: ${p => p.theme.subText};
}
`;
const ArithmeticError = styled(Tooltip)`
color: ${p => p.theme.errorText};
animation: ${() => pulse(1.15)} 1s ease infinite;
display: flex;
`;
export {QueryField};