123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664 |
- 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: <TypeBadge kind={classifyTagKey(column)} />,
- };
- }),
- ...Object.values(stringSpanTags).map(tag => {
- return {
- label: tag.name,
- value: tag.key,
- textValue: tag.name,
- trailingItems: <TypeBadge kind={FieldKind.TAG} />,
- };
- }),
- ...Object.values(numericSpanTags).map(tag => {
- return {
- label: tag.name,
- value: tag.key,
- textValue: tag.name,
- trailingItems: <TypeBadge kind={FieldKind.MEASUREMENT} />,
- };
- }),
- ];
- 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 (
- <Fragment>
- <SectionHeader
- title={t('Visualize')}
- tooltipText={t(
- 'Primary metric that appears in your chart. You can also overlay a series onto an existing chart or add an equation.'
- )}
- />
- <Fields>
- {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 ? (
- <TypeBadge kind={FieldKind.MEASUREMENT} />
- ) : 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 (
- <FieldRow key={index}>
- <FieldBar data-testid={'field-bar'}>
- {field.kind === FieldValueKind.EQUATION ? (
- <StyledArithmeticInput
- name="arithmetic"
- key="parameter:text"
- type="text"
- required
- value={field.field}
- onUpdate={value =>
- dispatch({
- type: updateAction,
- payload: fields.map((_field, i) =>
- i === index ? {..._field, field: value} : _field
- ),
- })
- }
- options={fields}
- placeholder={t('Equation')}
- aria-label={t('Equation')}
- />
- ) : (
- <Fragment>
- <PrimarySelectRow>
- <ColumnCompactSelect
- searchable
- options={
- state.dataset === WidgetType.SPANS &&
- field.kind !== FieldValueKind.FUNCTION
- ? spanColumnOptions
- : columnOptions
- }
- value={
- field.kind === FieldValueKind.FUNCTION
- ? parseFunction(stringFields?.[index] ?? '')?.arguments[0] ??
- ''
- : field.field
- }
- onChange={newField => {
- 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
- }
- />
- <AggregateCompactSelect
- disabled={aggregateOptions.length <= 1}
- options={aggregateOptions}
- value={parseFunction(stringFields?.[index] ?? '')?.name ?? ''}
- onChange={aggregateSelection => {
- 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'),
- }}
- />
- </PrimarySelectRow>
- {field.kind === FieldValueKind.FUNCTION &&
- parameterRefinements.length > 0 && (
- <ParameterRefinements>
- {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 (
- <AggregateParameter
- key={key}
- parameter={parameter}
- fieldValue={field}
- currentValue={currentValue}
- onChange={value => {
- const newFields = cloneDeep(fields);
- if (
- newFields[index]!.kind !== FieldValueKind.FUNCTION
- ) {
- return;
- }
- newFields[index]!.function[parameterIndex + 2] = value;
- dispatch({
- type: updateAction,
- payload: newFields,
- });
- }}
- />
- );
- })}
- </ParameterRefinements>
- )}
- </Fragment>
- )}
- </FieldBar>
- <FieldExtras isChartWidget={isChartWidget || isBigNumberWidget}>
- {!isChartWidget && !isBigNumberWidget && (
- <LegendAliasInput
- type="text"
- name="name"
- placeholder={t('Add Alias')}
- value={field.alias}
- onChange={e => {
- const newFields = cloneDeep(fields);
- newFields[index]!.alias = e.target.value;
- dispatch({
- type: updateAction,
- payload: newFields,
- });
- }}
- />
- )}
- <StyledDeleteButton
- borderless
- icon={<IconDelete />}
- size="zero"
- disabled={fields.length <= 1}
- onClick={() =>
- dispatch({
- type: updateAction,
- payload: fields?.filter((_field, i) => i !== index) ?? [],
- })
- }
- aria-label={t('Remove field')}
- />
- </FieldExtras>
- </FieldRow>
- );
- })}
- </Fields>
- <AddButtons>
- <AddButton
- priority="link"
- aria-label={isChartWidget ? t('Add Series') : t('Add Field')}
- onClick={() =>
- dispatch({
- type: updateAction,
- payload: [...(fields ?? []), cloneDeep(datasetConfig.defaultField)],
- })
- }
- >
- {isChartWidget ? t('+ Add Series') : t('+ Add Field')}
- </AddButton>
- {datasetConfig.enableEquations && (
- <AddButton
- priority="link"
- aria-label={t('Add Equation')}
- onClick={() =>
- dispatch({
- type: updateAction,
- payload: [...(fields ?? []), {kind: FieldValueKind.EQUATION, field: ''}],
- })
- }
- >
- {t('+ Add Equation')}
- </AddButton>
- )}
- </AddButtons>
- </Fragment>
- );
- }
- 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 (
- <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 (parameter.kind === 'dropdown') {
- return (
- <SelectControl
- key="dropdown"
- name="dropdown"
- menuPlacement="auto"
- placeholder={t('Select value')}
- options={parameter.options}
- value={currentValue}
- required={parameter.required}
- onChange={({value}) => {
- 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%;
- `;
|