import {Fragment, useMemo} from 'react'; import styled from '@emotion/styled'; import cloneDeep from 'lodash/cloneDeep'; import {Button} from 'sentry/components/button'; import {CompactSelect} from 'sentry/components/compactSelect'; import SelectControl from 'sentry/components/forms/controls/selectControl'; import Input from 'sentry/components/input'; import {IconDelete} from 'sentry/icons'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import { type AggregationKeyWithAlias, type AggregationRefinement, classifyTagKey, generateFieldAsString, parseFunction, prettifyTagKey, type QueryFieldValue, } from 'sentry/utils/discover/fields'; import {FieldKind} from 'sentry/utils/fields'; import useCustomMeasurements from 'sentry/utils/useCustomMeasurements'; import useOrganization from 'sentry/utils/useOrganization'; import useTags from 'sentry/utils/useTags'; import {getDatasetConfig} from 'sentry/views/dashboards/datasetConfig/base'; import {DisplayType, WidgetType} from 'sentry/views/dashboards/types'; import {SectionHeader} from 'sentry/views/dashboards/widgetBuilder/components/common/sectionHeader'; import {useWidgetBuilderContext} from 'sentry/views/dashboards/widgetBuilder/contexts/widgetBuilderContext'; import {BuilderStateAction} from 'sentry/views/dashboards/widgetBuilder/hooks/useWidgetBuilderState'; import ArithmeticInput from 'sentry/views/discover/table/arithmeticInput'; import { BufferedInput, type ParameterDescription, } from 'sentry/views/discover/table/queryField'; import {FieldValueKind} from 'sentry/views/discover/table/types'; import {TypeBadge} from 'sentry/views/explore/components/typeBadge'; import {useSpanTags} from 'sentry/views/explore/contexts/spanTagsContext'; type AggregateFunction = [ AggregationKeyWithAlias, string, AggregationRefinement, AggregationRefinement, ]; const MAX_FUNCTION_PARAMETERS = 4; const NONE = 'none'; const NONE_AGGREGATE = { label: t('None'), value: NONE, }; function Visualize() { const organization = useOrganization(); const {state, dispatch} = useWidgetBuilderContext(); let tags = useTags(); const {customMeasurements} = useCustomMeasurements(); const isChartWidget = state.displayType !== DisplayType.TABLE && state.displayType !== DisplayType.BIG_NUMBER; const isBigNumberWidget = state.displayType === DisplayType.BIG_NUMBER; const numericSpanTags = useSpanTags('number'); const stringSpanTags = useSpanTags('string'); // Span column options are explicitly defined and bypass all of the // fieldOptions filtering and logic used for showing options for // chart types. let spanColumnOptions; if (state.dataset === WidgetType.SPANS) { // Explicitly merge numeric and string tags to ensure filtering // compatibility for timeseries chart types. tags = {...numericSpanTags, ...stringSpanTags}; const columns = state.fields ?.filter(field => field.kind === FieldValueKind.FIELD) .map(field => field.field) ?? []; spanColumnOptions = [ // Columns that are not in the tag responses, e.g. old tags ...columns .filter( column => column !== '' && !stringSpanTags.hasOwnProperty(column) && !numericSpanTags.hasOwnProperty(column) ) .map(column => { return { label: prettifyTagKey(column), value: column, textValue: column, trailingItems: , }; }), ...Object.values(stringSpanTags).map(tag => { return { label: tag.name, value: tag.key, textValue: tag.name, trailingItems: , }; }), ...Object.values(numericSpanTags).map(tag => { return { label: tag.name, value: tag.key, textValue: tag.name, trailingItems: , }; }), ]; spanColumnOptions.sort((a, b) => { if (a.label < b.label) { return -1; } if (a.label > b.label) { return 1; } return 0; }); } const datasetConfig = useMemo(() => getDatasetConfig(state.dataset), [state.dataset]); const fields = isChartWidget ? state.yAxis : state.fields; const updateAction = isChartWidget ? BuilderStateAction.SET_Y_AXIS : BuilderStateAction.SET_FIELDS; const fieldOptions = useMemo( () => datasetConfig.getTableFieldOptions(organization, tags, customMeasurements), [organization, tags, customMeasurements, datasetConfig] ); const aggregates = useMemo( () => Object.values(fieldOptions).filter(option => datasetConfig.filterYAxisOptions?.(state.displayType ?? DisplayType.TABLE)(option) ), [fieldOptions, state.displayType, datasetConfig] ); // Used to extract selected aggregates and parameters from the fields const stringFields = fields?.map(generateFieldAsString); return ( {fields?.map((field, index) => { // Depending on the dataset and the display type, we use different options for // displaying in the column select. // For charts, we show aggregate parameter options for the y-axis as primary options. // For tables, we show all string tags and fields as primary options, as well // as aggregates that don't take parameters. const columnFilterMethod = isChartWidget ? datasetConfig.filterYAxisAggregateParams?.( field, state.displayType ?? DisplayType.LINE ) : field.kind === FieldValueKind.FUNCTION ? datasetConfig.filterAggregateParams : datasetConfig.filterTableOptions; const columnOptions = Object.values(fieldOptions) .filter(option => { // Don't show any aggregates under the columns, and if // there isn't a filter method, just show the option return ( option.value.kind !== FieldValueKind.FUNCTION && (columnFilterMethod?.(option, field) ?? true) ); }) .map(option => ({ value: option.value.meta.name, label: state.dataset === WidgetType.SPANS ? prettifyTagKey(option.value.meta.name) : option.value.meta.name, // For the spans dataset, all of the options are measurements, // so we force the number badge to show trailingItems: state.dataset === WidgetType.SPANS ? ( ) : null, })); let aggregateOptions = aggregates.map(option => ({ value: option.value.meta.name, label: option.value.meta.name, })); aggregateOptions = isChartWidget || isBigNumberWidget ? aggregateOptions : [NONE_AGGREGATE, ...aggregateOptions]; let matchingAggregate; if ( fields[index]!.kind === FieldValueKind.FUNCTION && FieldValueKind.FUNCTION in fields[index]! ) { matchingAggregate = aggregates.find( option => option.value.meta.name === parseFunction(stringFields?.[index] ?? '')?.name ); } const parameterRefinements = matchingAggregate?.value.meta.parameters.length > 1 ? matchingAggregate?.value.meta.parameters.slice(1) : []; return ( {field.kind === FieldValueKind.EQUATION ? ( dispatch({ type: updateAction, payload: fields.map((_field, i) => i === index ? {..._field, field: value} : _field ), }) } options={fields} placeholder={t('Equation')} aria-label={t('Equation')} /> ) : ( { const newFields = cloneDeep(fields); const currentField = newFields[index]!; // Update the current field's aggregate with the new aggregate if (currentField.kind === FieldValueKind.FUNCTION) { currentField.function[1] = newField.value as string; } if (currentField.kind === FieldValueKind.FIELD) { currentField.field = newField.value as string; } dispatch({ type: updateAction, payload: newFields, }); }} triggerProps={{ 'aria-label': t('Column Selection'), }} disabled={ fields[index]!.kind === FieldValueKind.FUNCTION && matchingAggregate?.value.meta.parameters.length === 0 } /> { const isNone = aggregateSelection.value === NONE; const newFields = cloneDeep(fields); const currentField = newFields[index]!; const newAggregate = aggregates.find( option => option.value.meta.name === aggregateSelection.value ); // Update the current field's aggregate with the new aggregate if (!isNone) { if (currentField.kind === FieldValueKind.FUNCTION) { // Handle setting an aggregate from an aggregate currentField.function[0] = aggregateSelection.value as AggregationKeyWithAlias; if ( newAggregate?.value.meta && 'parameters' in newAggregate.value.meta ) { // There are aggregates that have no parameters, so wipe out the argument // if it's supposed to be empty if (newAggregate.value.meta.parameters.length === 0) { currentField.function[1] = ''; } else { currentField.function[1] = (currentField.function[1] || newAggregate.value.meta.parameters[0]! .defaultValue) ?? ''; // Set the remaining parameters for the new aggregate for ( let i = 1; // The first parameter is the column selection i < newAggregate.value.meta.parameters.length; i++ ) { // Increment by 1 to skip past the aggregate name currentField.function[i + 1] = newAggregate.value.meta.parameters[i]!.defaultValue; } } // Wipe out the remaining parameters that are unnecessary // This is necessary for transitioning between aggregates that have // more parameters to ones of fewer parameters for ( let i = newAggregate.value.meta.parameters.length; i < MAX_FUNCTION_PARAMETERS; i++ ) { currentField.function[i + 1] = undefined; } } } else { if ( !newAggregate || !('parameters' in newAggregate.value.meta) ) { return; } // Handle setting an aggregate from a field const newFunction: AggregateFunction = [ aggregateSelection.value as AggregationKeyWithAlias, (currentField.field || newAggregate?.value.meta?.parameters?.[0] ?.defaultValue) ?? '', newAggregate?.value.meta?.parameters?.[1]?.defaultValue ?? undefined, newAggregate?.value.meta?.parameters?.[2]?.defaultValue ?? undefined, ]; if ( newAggregate?.value.meta && 'parameters' in newAggregate.value.meta ) { newAggregate?.value.meta.parameters.forEach( (parameter, parameterIndex) => { // Increment by 1 to skip past the aggregate name newFunction[parameterIndex + 1] = newFunction[parameterIndex + 1] ?? parameter.defaultValue; } ); } newFields[index] = { kind: FieldValueKind.FUNCTION, function: newFunction, }; } } else { // Handle selecting None so we can select just a field, e.g. for samples // If none is selected, set the field to a field value newFields[index] = { kind: FieldValueKind.FIELD, field: 'function' in currentField ? (currentField.function[1] as string) ?? columnOptions[0]!.value : '', }; } dispatch({ type: updateAction, payload: newFields, }); }} triggerProps={{ 'aria-label': t('Aggregate Selection'), }} /> {field.kind === FieldValueKind.FUNCTION && parameterRefinements.length > 0 && ( {parameterRefinements.map((parameter, parameterIndex) => { // The current value is displaced by 2 because the first two parameters // are the aggregate name and the column selection const currentValue = field.function[parameterIndex + 2] || ''; const key = `${field.function.join('_')}-${parameterIndex}`; return ( { const newFields = cloneDeep(fields); if ( newFields[index]!.kind !== FieldValueKind.FUNCTION ) { return; } newFields[index]!.function[parameterIndex + 2] = value; dispatch({ type: updateAction, payload: newFields, }); }} /> ); })} )} )} {!isChartWidget && !isBigNumberWidget && ( { const newFields = cloneDeep(fields); newFields[index]!.alias = e.target.value; dispatch({ type: updateAction, payload: newFields, }); }} /> )} } size="zero" disabled={fields.length <= 1} onClick={() => dispatch({ type: updateAction, payload: fields?.filter((_field, i) => i !== index) ?? [], }) } aria-label={t('Remove field')} /> ); })} dispatch({ type: updateAction, payload: [...(fields ?? []), cloneDeep(datasetConfig.defaultField)], }) } > {isChartWidget ? t('+ Add Series') : t('+ Add Field')} {datasetConfig.enableEquations && ( dispatch({ type: updateAction, payload: [...(fields ?? []), {kind: FieldValueKind.EQUATION, field: ''}], }) } > {t('+ Add Equation')} )} ); } export default Visualize; function AggregateParameter({ parameter, fieldValue, onChange, currentValue, }: { currentValue: string; fieldValue: QueryFieldValue; onChange: (value: string) => void; parameter: ParameterDescription; }) { if (parameter.kind === 'value') { const inputProps = { required: parameter.required, value: parameter.value ?? ('defaultValue' in parameter && parameter?.defaultValue) ?? '', onUpdate: value => { onChange(value); }, placeholder: parameter.placeholder, }; switch (parameter.dataType) { case 'number': return ( ); case 'integer': return ( ); default: return ( ); } } if (parameter.kind === 'dropdown') { return ( { onChange(value); }} /> ); } throw new Error(`Unknown parameter type encountered for ${fieldValue}`); } const ColumnCompactSelect = styled(CompactSelect)` flex: 1 1 auto; min-width: 0; > button { width: 100%; } `; const AggregateCompactSelect = styled(CompactSelect)` width: fit-content; max-width: 150px; left: -1px; > button { width: 100%; } `; const LegendAliasInput = styled(Input)``; const ParameterRefinements = styled('div')` display: flex; flex-direction: row; gap: ${space(1)}; > * { flex: 1; } `; const FieldBar = styled('div')` display: grid; grid-template-columns: 1fr; gap: ${space(1)}; flex: 3; `; const PrimarySelectRow = styled('div')` display: flex; width: 100%; flex: 3; & > ${ColumnCompactSelect} > button { border-top-right-radius: 0; border-bottom-right-radius: 0; } & > ${AggregateCompactSelect} > button { border-top-left-radius: 0; border-bottom-left-radius: 0; } `; const FieldRow = styled('div')` display: flex; flex-direction: row; gap: ${space(1)}; `; const StyledDeleteButton = styled(Button)``; const FieldExtras = styled('div')<{isChartWidget: boolean}>` display: flex; flex-direction: row; gap: ${space(1)}; flex: ${p => (p.isChartWidget ? '0' : '1')}; `; const AddButton = styled(Button)` margin-top: ${space(1)}; `; const AddButtons = styled('div')` display: inline-flex; gap: ${space(1.5)}; `; const Fields = styled('div')` display: flex; flex-direction: column; gap: ${space(1)}; `; const StyledArithmeticInput = styled(ArithmeticInput)` width: 100%; `;