From 2b3315a295a5d5a9b2cc4d2d3677557d5104788e Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Tue, 29 Mar 2022 15:44:06 +0200 Subject: [PATCH 01/12] feat(new-widget-builder-experience): Add Metrics dataset --- static/app/actionCreators/tags.tsx | 1 + .../dashboards/issueWidgetQueriesForm.tsx | 2 +- .../dashboards/widgetQueriesForm.tsx | 1 + static/app/stores/metricsMetaStore.tsx | 13 ++- static/app/stores/metricsTagStore.tsx | 13 ++- static/app/utils/useMetricMetas.tsx | 55 +++++++++++++ static/app/utils/useMetricTags.tsx | 54 +++++++++++++ static/app/views/dashboardsV2/dashboard.tsx | 19 ++++- .../buildSteps/columnsStep/columnFields.tsx | 9 +++ .../buildSteps/columnsStep/index.tsx | 12 ++- .../columnsStep/releaseColumnFields.tsx | 79 +++++++++++++++++++ .../widgetBuilder/buildSteps/dataSetStep.tsx | 45 ++++++++--- .../buildSteps/filterResultsStep/index.tsx | 13 ++- .../filterResultsStep/issuesSearchBar.tsx | 6 +- .../filterResultsStep/releaseSearchBar.tsx | 39 +++++++++ .../buildSteps/yAxisStep/index.tsx | 39 ++++++--- .../yAxisStep/releaseYAxisSelector.tsx | 42 ++++++++++ .../yAxisStep/yAxisSelector/index.tsx | 22 +++--- .../dashboardsV2/widgetBuilder/utils.tsx | 2 +- .../widgetBuilder/widgetBuilder.tsx | 23 ++++-- .../views/performance/metricsSearchBar.tsx | 31 +------- .../widgetBuilder/widgetBuilder.spec.tsx | 10 +++ 22 files changed, 443 insertions(+), 87 deletions(-) create mode 100644 static/app/utils/useMetricMetas.tsx create mode 100644 static/app/utils/useMetricTags.tsx create mode 100644 static/app/views/dashboardsV2/widgetBuilder/buildSteps/columnsStep/releaseColumnFields.tsx create mode 100644 static/app/views/dashboardsV2/widgetBuilder/buildSteps/filterResultsStep/releaseSearchBar.tsx create mode 100644 static/app/views/dashboardsV2/widgetBuilder/buildSteps/yAxisStep/releaseYAxisSelector.tsx diff --git a/static/app/actionCreators/tags.tsx b/static/app/actionCreators/tags.tsx index 397c4ac1537b11..f4c5ae4b5d03dd 100644 --- a/static/app/actionCreators/tags.tsx +++ b/static/app/actionCreators/tags.tsx @@ -69,6 +69,7 @@ export function fetchOrganizationTags( method: 'GET', query, }); + promise.then(tagFetchSuccess, TagActions.loadTagsError); return promise; diff --git a/static/app/components/dashboards/issueWidgetQueriesForm.tsx b/static/app/components/dashboards/issueWidgetQueriesForm.tsx index e64e1c96f11a4e..b82e03cf6a6bb8 100644 --- a/static/app/components/dashboards/issueWidgetQueriesForm.tsx +++ b/static/app/components/dashboards/issueWidgetQueriesForm.tsx @@ -13,7 +13,7 @@ import { getColumnsAndAggregates, } from 'sentry/utils/discover/fields'; import {DisplayType, WidgetQuery, WidgetType} from 'sentry/views/dashboardsV2/types'; -import IssuesSearchBar from 'sentry/views/dashboardsV2/widgetBuilder/buildSteps/filterResultsStep/issuesSearchBar'; +import {IssuesSearchBar} from 'sentry/views/dashboardsV2/widgetBuilder/buildSteps/filterResultsStep/issuesSearchBar'; import {generateIssueWidgetOrderOptions} from 'sentry/views/dashboardsV2/widgetBuilder/issueWidget/utils'; import {generateFieldOptions} from 'sentry/views/eventsV2/utils'; import {IssueSortOptions} from 'sentry/views/issueList/utils'; diff --git a/static/app/components/dashboards/widgetQueriesForm.tsx b/static/app/components/dashboards/widgetQueriesForm.tsx index 87db1169285cf4..45255b59b8ed72 100644 --- a/static/app/components/dashboards/widgetQueriesForm.tsx +++ b/static/app/components/dashboards/widgetQueriesForm.tsx @@ -191,6 +191,7 @@ class WidgetQueriesForm extends React.Component { onChange, widgetType = WidgetType.DISCOVER, } = this.props; + const isMetrics = widgetType === WidgetType.METRICS; const hideLegendAlias = ['table', 'world_map', 'big_number'].includes(displayType); diff --git a/static/app/stores/metricsMetaStore.tsx b/static/app/stores/metricsMetaStore.tsx index 63d71533fb5bd2..5173c9ccbbd90c 100644 --- a/static/app/stores/metricsMetaStore.tsx +++ b/static/app/stores/metricsMetaStore.tsx @@ -7,10 +7,12 @@ import {makeSafeRefluxStore} from 'sentry/utils/makeSafeRefluxStore'; import {CommonStoreDefinition} from './types'; type State = { + isFetching: boolean; metricsMeta: MetricsMetaCollection; }; type InternalDefinition = { + isFetching: boolean; metricsMeta: MetricsMetaCollection; }; @@ -24,6 +26,7 @@ interface MetricsMetaStoreDefinition const storeConfig: MetricsMetaStoreDefinition = { unsubscribeListeners: [], metricsMeta: {}, + isFetching: false, init() { this.unsubscribeListeners.push( @@ -33,12 +36,13 @@ const storeConfig: MetricsMetaStoreDefinition = { reset() { this.metricsMeta = {}; - this.trigger(this.metricsMeta); + this.isFetching = true; + this.trigger(this.state); }, getState() { - const {metricsMeta} = this; - return {metricsMeta}; + const {metricsMeta, isFetching} = this; + return {metricsMeta, isFetching}; }, onLoadSuccess(data) { @@ -51,7 +55,8 @@ const storeConfig: MetricsMetaStoreDefinition = { }, {}); this.metricsMeta = {...this.metricsMeta, ...newFields}; - this.trigger(this.metricsMeta); + this.isFetching = false; + this.trigger(this.state); }, }; diff --git a/static/app/stores/metricsTagStore.tsx b/static/app/stores/metricsTagStore.tsx index 006c2a0d0497da..99819eb5515954 100644 --- a/static/app/stores/metricsTagStore.tsx +++ b/static/app/stores/metricsTagStore.tsx @@ -7,10 +7,12 @@ import {makeSafeRefluxStore} from 'sentry/utils/makeSafeRefluxStore'; import {CommonStoreDefinition} from './types'; type State = { + isFetching: boolean; metricsTags: MetricsTagCollection; }; type InternalDefinition = { + isFetching: boolean; metricsTags: MetricsTagCollection; }; @@ -24,6 +26,7 @@ interface MetricsTagStoreDefinition const storeConfig: MetricsTagStoreDefinition = { unsubscribeListeners: [], metricsTags: {}, + isFetching: false, init() { this.unsubscribeListeners.push( @@ -33,12 +36,13 @@ const storeConfig: MetricsTagStoreDefinition = { reset() { this.metricsTags = {}; - this.trigger(this.metricsTags); + this.isFetching = true; + this.trigger(this.state); }, getState() { - const {metricsTags} = this; - return {metricsTags}; + const {metricsTags, isFetching} = this; + return {metricsTags, isFetching}; }, onLoadSuccess(data) { @@ -51,7 +55,8 @@ const storeConfig: MetricsTagStoreDefinition = { }, {}); this.metricsTags = {...this.metricsTags, ...newTags}; - this.trigger(this.metricsTags); + this.isFetching = false; + this.trigger(this.state); }, }; diff --git a/static/app/utils/useMetricMetas.tsx b/static/app/utils/useMetricMetas.tsx new file mode 100644 index 00000000000000..6bc5e0e529a55f --- /dev/null +++ b/static/app/utils/useMetricMetas.tsx @@ -0,0 +1,55 @@ +import {useEffect} from 'react'; + +import {addErrorMessage} from 'sentry/actionCreators/indicator'; +import {t} from 'sentry/locale'; +import MetricsMetaStore from 'sentry/stores/metricsMetaStore'; +import PageFiltersStore from 'sentry/stores/pageFiltersStore'; +import {useLegacyStore} from 'sentry/stores/useLegacyStore'; +import handleXhrErrorResponse from 'sentry/utils/handleXhrErrorResponse'; +import useApi from 'sentry/utils/useApi'; + +import useOrganization from './useOrganization'; + +export function useMetricMetas() { + const api = useApi(); + const organization = useOrganization(); + const {selection} = useLegacyStore(PageFiltersStore); + const {metricsMeta, isFetching} = useLegacyStore(MetricsMetaStore); + + useEffect(() => { + let unmounted = false; + + if (!unmounted) { + fetchMetricMetas(); + } + + return () => { + unmounted = true; + }; + }, [selection.projects, organization.slug]); + + async function fetchMetricMetas() { + if (isFetching) { + return; + } + + MetricsMetaStore.reset(); + try { + const metas = await api.requestPromise( + `/organizations/${organization.slug}/metrics/meta/`, + { + query: { + project: selection.projects, + }, + } + ); + MetricsMetaStore.onLoadSuccess(metas); + } catch (error) { + const errorResponse = error?.responseJSON ?? t('Unable to fetch metric metas'); + addErrorMessage(errorResponse); + handleXhrErrorResponse(errorResponse)(error); + } + } + + return {metricMetas: metricsMeta}; +} diff --git a/static/app/utils/useMetricTags.tsx b/static/app/utils/useMetricTags.tsx new file mode 100644 index 00000000000000..bdbf5c5c240ae2 --- /dev/null +++ b/static/app/utils/useMetricTags.tsx @@ -0,0 +1,54 @@ +import {useEffect} from 'react'; + +import {addErrorMessage} from 'sentry/actionCreators/indicator'; +import {t} from 'sentry/locale'; +import MetricsTagStore from 'sentry/stores/metricsTagStore'; +import PageFiltersStore from 'sentry/stores/pageFiltersStore'; +import {useLegacyStore} from 'sentry/stores/useLegacyStore'; +import handleXhrErrorResponse from 'sentry/utils/handleXhrErrorResponse'; +import useApi from 'sentry/utils/useApi'; + +import useOrganization from './useOrganization'; + +export function useMetricTags() { + const api = useApi(); + const organization = useOrganization(); + const {selection} = useLegacyStore(PageFiltersStore); + const {metricsTags, isFetching} = useLegacyStore(MetricsTagStore); + + useEffect(() => { + let unmounted = false; + + if (!unmounted) { + fetchMetricTags(); + } + + return () => { + unmounted = true; + }; + }, [selection.projects, organization.slug]); + + async function fetchMetricTags() { + if (isFetching) { + return; + } + MetricsTagStore.reset(); + try { + const tags = await api.requestPromise( + `/organizations/${organization.slug}/metrics/tags/`, + { + query: { + project: selection.projects, + }, + } + ); + MetricsTagStore.onLoadSuccess(tags); + } catch (error) { + const errorResponse = error?.responseJSON ?? t('Unable to fetch metric tags'); + addErrorMessage(errorResponse); + handleXhrErrorResponse(errorResponse)(error); + } + } + + return {metricTags: metricsTags}; +} diff --git a/static/app/views/dashboardsV2/dashboard.tsx b/static/app/views/dashboardsV2/dashboard.tsx index aec003f3152964..c0666e27d7987c 100644 --- a/static/app/views/dashboardsV2/dashboard.tsx +++ b/static/app/views/dashboardsV2/dashboard.tsx @@ -148,9 +148,13 @@ class Dashboard extends Component { window.addEventListener('resize', this.debouncedHandleResize); } - if (organization.features.includes('dashboards-metrics')) { + if ( + organization.features.includes('dashboards-metrics') && + !organization.features.includes('new-widget-builder-experience') && + organization.features.includes('new-widget-builder-experience-modal-access') + ) { fetchMetricsFields(api, organization.slug, selection.projects); - fetchMetricsTags(api, organization.slug, selection.projects); + // fetchMetricsTags(api, organization.slug, selection.projects); } // Load organization tags when in edit mode. if (isEditing) { @@ -178,8 +182,15 @@ class Dashboard extends Component { } if (!isEqual(prevProps.selection.projects, selection.projects)) { this.fetchMemberList(); - fetchMetricsFields(api, organization.slug, selection.projects); - fetchMetricsTags(api, organization.slug, selection.projects); + + if ( + organization.features.includes('dashboards-metrics') && + !organization.features.includes('new-widget-builder-experience') && + organization.features.includes('new-widget-builder-experience-modal-access') + ) { + fetchMetricsFields(api, organization.slug, selection.projects); + // fetchMetricsTags(api, organization.slug, selection.projects); + } } } diff --git a/static/app/views/dashboardsV2/widgetBuilder/buildSteps/columnsStep/columnFields.tsx b/static/app/views/dashboardsV2/widgetBuilder/buildSteps/columnsStep/columnFields.tsx index abaee9d72be6d6..545c974e47f9b9 100644 --- a/static/app/views/dashboardsV2/widgetBuilder/buildSteps/columnsStep/columnFields.tsx +++ b/static/app/views/dashboardsV2/widgetBuilder/buildSteps/columnsStep/columnFields.tsx @@ -6,6 +6,7 @@ import {Organization} from 'sentry/types'; import {QueryFieldValue} from 'sentry/utils/discover/fields'; import {DisplayType, WidgetType} from 'sentry/views/dashboardsV2/types'; import ColumnEditCollection from 'sentry/views/eventsV2/table/columnEditCollection'; +import {FieldValueOption} from 'sentry/views/eventsV2/table/queryField'; import {generateFieldOptions} from 'sentry/views/eventsV2/utils'; interface Props { @@ -16,6 +17,8 @@ interface Props { organization: Organization; widgetType: WidgetType; errors?: Record[]; + filterPrimaryOptions?: (option: FieldValueOption) => boolean; + noFieldsMessage?: string; } export function ColumnFields({ @@ -26,6 +29,8 @@ export function ColumnFields({ organization, errors, onChange, + filterPrimaryOptions, + noFieldsMessage, }: Props) { return ( ) : ( // The only other display type this component @@ -57,6 +64,8 @@ export function ColumnFields({ fieldOptions={fieldOptions} organization={organization} source={widgetType} + filterPrimaryOptions={filterPrimaryOptions} + noFieldsMessage={noFieldsMessage} /> )} diff --git a/static/app/views/dashboardsV2/widgetBuilder/buildSteps/columnsStep/index.tsx b/static/app/views/dashboardsV2/widgetBuilder/buildSteps/columnsStep/index.tsx index a92b14017e694e..c52b532ce41b32 100644 --- a/static/app/views/dashboardsV2/widgetBuilder/buildSteps/columnsStep/index.tsx +++ b/static/app/views/dashboardsV2/widgetBuilder/buildSteps/columnsStep/index.tsx @@ -16,6 +16,7 @@ import {DataSet, getAmendedFieldOptions} from '../../utils'; import {BuildStep} from '../buildStep'; import {ColumnFields} from './columnFields'; +import {ReleaseColumnFields} from './releaseColumnFields'; interface Props { dataSet: DataSet; @@ -82,7 +83,7 @@ export function ColumnsStep({ /> )} - ) : ( + ) : dataSet === DataSet.ISSUES ? ( + ) : ( + )} ); diff --git a/static/app/views/dashboardsV2/widgetBuilder/buildSteps/columnsStep/releaseColumnFields.tsx b/static/app/views/dashboardsV2/widgetBuilder/buildSteps/columnsStep/releaseColumnFields.tsx new file mode 100644 index 00000000000000..a524524f12eeed --- /dev/null +++ b/static/app/views/dashboardsV2/widgetBuilder/buildSteps/columnsStep/releaseColumnFields.tsx @@ -0,0 +1,79 @@ +import {t} from 'sentry/locale'; +import {Organization} from 'sentry/types'; +import { + aggregateFunctionOutputType, + isLegalYAxisType, + QueryFieldValue, +} from 'sentry/utils/discover/fields'; +import {useMetricMetas} from 'sentry/utils/useMetricMetas'; +import {useMetricTags} from 'sentry/utils/useMetricTags'; +import {DisplayType, WidgetType} from 'sentry/views/dashboardsV2/types'; +import {generateMetricsWidgetFieldOptions} from 'sentry/views/dashboardsV2/widgetBuilder/metricWidget/fields'; +import {FieldValueOption} from 'sentry/views/eventsV2/table/queryField'; +import {FieldValueKind} from 'sentry/views/eventsV2/table/types'; + +import {ColumnFields} from './columnFields'; + +interface Props { + displayType: DisplayType; + explodedFields: QueryFieldValue[]; + onYAxisOrColumnFieldChange: (newFields: QueryFieldValue[]) => void; + organization: Organization; + widgetType: WidgetType; + queryErrors?: Record[]; +} + +export function ReleaseColumnFields({ + displayType, + organization, + widgetType, + explodedFields, + queryErrors, + onYAxisOrColumnFieldChange, +}: Props) { + const {metricTags} = useMetricTags(); + const {metricMetas} = useMetricMetas(); + // Any function/field choice for Big Number widgets is legal since the + // data source is from an endpoint that is not timeseries-based. + // The function/field choice for World Map widget will need to be numeric-like. + // Column builder for Table widget is already handled above. + const doNotValidateYAxis = displayType === DisplayType.BIG_NUMBER; + + function filterPrimaryOptions(option: FieldValueOption) { + if (displayType === DisplayType.TABLE) { + return [FieldValueKind.FUNCTION, FieldValueKind.TAG].includes(option.value.kind); + } + + // Only validate function names for timeseries widgets and + // world map widgets. + if (!doNotValidateYAxis && option.value.kind === FieldValueKind.FUNCTION) { + const primaryOutput = aggregateFunctionOutputType( + option.value.meta.name, + undefined + ); + if (primaryOutput) { + // If a function returns a specific type, then validate it. + return isLegalYAxisType(primaryOutput); + } + } + + return option.value.kind === FieldValueKind.FUNCTION; + } + + return ( + key) + )} + filterPrimaryOptions={filterPrimaryOptions} + onChange={onYAxisOrColumnFieldChange} + noFieldsMessage={t('There are no metrics for this project.')} + /> + ); +} diff --git a/static/app/views/dashboardsV2/widgetBuilder/buildSteps/dataSetStep.tsx b/static/app/views/dashboardsV2/widgetBuilder/buildSteps/dataSetStep.tsx index 8a2929cdc1c411..0469c0b8578010 100644 --- a/static/app/views/dashboardsV2/widgetBuilder/buildSteps/dataSetStep.tsx +++ b/static/app/views/dashboardsV2/widgetBuilder/buildSteps/dataSetStep.tsx @@ -1,7 +1,8 @@ import styled from '@emotion/styled'; -import RadioGroup from 'sentry/components/forms/controls/radioGroup'; +import RadioGroup, {RadioGroupProps} from 'sentry/components/forms/controls/radioGroup'; import {t} from 'sentry/locale'; +import space from 'sentry/styles/space'; import {DisplayType} from 'sentry/views/dashboardsV2/types'; import {DataSet} from '../utils'; @@ -11,16 +12,39 @@ import {BuildStep} from './buildStep'; const DATASET_CHOICES: [DataSet, string][] = [ [DataSet.EVENTS, t('Events (Errors, transactions)')], [DataSet.ISSUES, t('Issues (Status, assignee, etc.)')], - // [DataSet.METRICS, t('Metrics (Release Health)')], ]; interface Props { dataSet: DataSet; displayType: DisplayType; onChange: (dataSet: DataSet) => void; + widgetBuilderNewDesign: boolean; } -export function DataSetStep({dataSet, onChange, displayType}: Props) { +export function DataSetStep({ + dataSet, + onChange, + widgetBuilderNewDesign, + displayType, +}: Props) { + const disabledChoices: RadioGroupProps['disabledChoices'] = []; + + if (displayType !== DisplayType.TABLE) { + disabledChoices.push([ + DataSet.ISSUES, + t('This data set is restricted to tabular visualization.'), + ]); + + if (displayType === DisplayType.WORLD_MAP) { + disabledChoices.push([ + DataSet.RELEASE, + t( + 'This data set is restricted to big number, tabular and time series visualizations.' + ), + ]); + } + } + return ( { onChange(newDataSet as DataSet); }} @@ -51,6 +73,7 @@ export function DataSetStep({dataSet, onChange, displayType}: Props) { } const DataSetChoices = styled(RadioGroup)` + gap: ${space(2)}; @media (min-width: ${p => p.theme.breakpoints[2]}) { grid-auto-flow: column; } diff --git a/static/app/views/dashboardsV2/widgetBuilder/buildSteps/filterResultsStep/index.tsx b/static/app/views/dashboardsV2/widgetBuilder/buildSteps/filterResultsStep/index.tsx index d6ada1faf86a87..9edcdc0fa89109 100644 --- a/static/app/views/dashboardsV2/widgetBuilder/buildSteps/filterResultsStep/index.tsx +++ b/static/app/views/dashboardsV2/widgetBuilder/buildSteps/filterResultsStep/index.tsx @@ -13,7 +13,8 @@ import {WidgetQuery, WidgetType} from 'sentry/views/dashboardsV2/types'; import {BuildStep} from '../buildStep'; import {EventsSearchBar} from './eventsSearchBar'; -import IssuesSearchBar from './issuesSearchBar'; +import {IssuesSearchBar} from './issuesSearchBar'; +import {ReleaseSearchBar} from './releaseSearchBar'; interface Props { canAddSearchConditions: boolean; @@ -116,7 +117,7 @@ export function FilterResultsStep({ selection={selection} searchSource="widget_builder" /> - ) : ( + ) : widgetType === WidgetType.DISCOVER ? ( + ) : ( + )} {!hideLegendAlias && ( + ); +} + +const SearchBar = styled(MetricsSearchBar)` + flex-grow: 1; +`; diff --git a/static/app/views/dashboardsV2/widgetBuilder/buildSteps/yAxisStep/index.tsx b/static/app/views/dashboardsV2/widgetBuilder/buildSteps/yAxisStep/index.tsx index a0560f738be8d5..034f3aebc2aa4b 100644 --- a/static/app/views/dashboardsV2/widgetBuilder/buildSteps/yAxisStep/index.tsx +++ b/static/app/views/dashboardsV2/widgetBuilder/buildSteps/yAxisStep/index.tsx @@ -4,13 +4,15 @@ import {QueryFieldValue} from 'sentry/utils/discover/fields'; import Measurements from 'sentry/utils/measurements/measurements'; import {DisplayType, WidgetType} from 'sentry/views/dashboardsV2/types'; -import {getAmendedFieldOptions} from '../../utils'; +import {DataSet, getAmendedFieldOptions} from '../../utils'; import {BuildStep} from '../buildStep'; +import {ReleaseYAxisSelector} from './releaseYAxisSelector'; import {YAxisSelector} from './yAxisSelector'; interface Props { aggregates: QueryFieldValue[]; + dataSet: DataSet; displayType: DisplayType; onYAxisChange: (newFields: QueryFieldValue[]) => void; organization: Organization; @@ -21,6 +23,7 @@ interface Props { export function YAxisStep({ displayType, + dataSet, queryErrors, aggregates, onYAxisChange, @@ -43,18 +46,28 @@ export function YAxisStep({ : t("This is the data you'd be visualizing in the display.") } > - - {({measurements}) => ( - - )} - + {dataSet === DataSet.RELEASE ? ( + + ) : ( + + {({measurements}) => ( + + )} + + )} ); } diff --git a/static/app/views/dashboardsV2/widgetBuilder/buildSteps/yAxisStep/releaseYAxisSelector.tsx b/static/app/views/dashboardsV2/widgetBuilder/buildSteps/yAxisStep/releaseYAxisSelector.tsx new file mode 100644 index 00000000000000..517f1647f3d70c --- /dev/null +++ b/static/app/views/dashboardsV2/widgetBuilder/buildSteps/yAxisStep/releaseYAxisSelector.tsx @@ -0,0 +1,42 @@ +import {t} from 'sentry/locale'; +import {QueryFieldValue} from 'sentry/utils/discover/fields'; +import {useMetricMetas} from 'sentry/utils/useMetricMetas'; +import {useMetricTags} from 'sentry/utils/useMetricTags'; +import {DisplayType, WidgetType} from 'sentry/views/dashboardsV2/types'; +import {generateMetricsWidgetFieldOptions} from 'sentry/views/dashboardsV2/widgetBuilder/metricWidget/fields'; + +import {YAxisSelector} from './yAxisSelector'; + +interface Props { + aggregates: QueryFieldValue[]; + displayType: DisplayType; + onChange: (newFields: QueryFieldValue[]) => void; + widgetType: WidgetType; + errors?: Record[]; +} + +export function ReleaseYAxisSelector({ + aggregates, + displayType, + widgetType, + onChange, + errors, +}: Props) { + const {metricTags} = useMetricTags(); + const {metricMetas} = useMetricMetas(); + + return ( + key) + )} + noFieldsMessage={t('There are no metrics for this project.')} + /> + ); +} diff --git a/static/app/views/dashboardsV2/widgetBuilder/buildSteps/yAxisStep/yAxisSelector/index.tsx b/static/app/views/dashboardsV2/widgetBuilder/buildSteps/yAxisStep/yAxisSelector/index.tsx index 2988f9c7667f87..4b0301599181d5 100644 --- a/static/app/views/dashboardsV2/widgetBuilder/buildSteps/yAxisStep/yAxisSelector/index.tsx +++ b/static/app/views/dashboardsV2/widgetBuilder/buildSteps/yAxisStep/yAxisSelector/index.tsx @@ -28,6 +28,7 @@ interface Props { onChange: (aggregates: QueryFieldValue[]) => void; widgetType: Widget['widgetType']; errors?: Record; + noFieldsMessage?: string; } export function YAxisSelector({ @@ -37,6 +38,7 @@ export function YAxisSelector({ fieldOptions, onChange, errors, + noFieldsMessage, }: Props) { const organization = useOrganization(); const isMetricWidget = widgetType === WidgetType.METRICS; @@ -87,6 +89,15 @@ export function YAxisSelector({ const doNotValidateYAxis = displayType === DisplayType.BIG_NUMBER; function filterPrimaryOptions(option: FieldValueOption) { + if (widgetType === WidgetType.METRICS) { + if (displayType === DisplayType.TABLE) { + return [FieldValueKind.FUNCTION, FieldValueKind.TAG].includes(option.value.kind); + } + if (displayType === DisplayType.TOP_N) { + return option.value.kind === FieldValueKind.TAG; + } + } + // Only validate function names for timeseries widgets and // world map widgets. if (!doNotValidateYAxis && option.value.kind === FieldValueKind.FUNCTION) { @@ -100,16 +111,6 @@ export function YAxisSelector({ } } - if ( - widgetType === WidgetType.METRICS && - (displayType === DisplayType.TABLE || displayType === DisplayType.TOP_N) - ) { - return ( - option.value.kind === FieldValueKind.FUNCTION || - option.value.kind === FieldValueKind.TAG - ); - } - return option.value.kind === FieldValueKind.FUNCTION; } @@ -185,6 +186,7 @@ export function YAxisSelector({ filterPrimaryOptions={filterPrimaryOptions} filterAggregateParameters={filterAggregateParameters(fieldValue)} otherColumns={aggregates} + noFieldsMessage={noFieldsMessage} /> {aggregates.length > 1 && (canDelete || fieldValue.kind === FieldValueKind.EQUATION) && ( diff --git a/static/app/views/dashboardsV2/widgetBuilder/utils.tsx b/static/app/views/dashboardsV2/widgetBuilder/utils.tsx index dc9818fe08eb2f..f65dbdbacf25e0 100644 --- a/static/app/views/dashboardsV2/widgetBuilder/utils.tsx +++ b/static/app/views/dashboardsV2/widgetBuilder/utils.tsx @@ -26,7 +26,7 @@ export const RESULTS_LIMIT = 10; export enum DataSet { EVENTS = 'events', ISSUES = 'issues', - METRICS = 'metrics', + RELEASE = 'release', } export enum SortDirection { diff --git a/static/app/views/dashboardsV2/widgetBuilder/widgetBuilder.tsx b/static/app/views/dashboardsV2/widgetBuilder/widgetBuilder.tsx index 02c8d5cf129d05..7dfb1ecc80af0d 100644 --- a/static/app/views/dashboardsV2/widgetBuilder/widgetBuilder.tsx +++ b/static/app/views/dashboardsV2/widgetBuilder/widgetBuilder.tsx @@ -101,7 +101,7 @@ function getDataSetQuery(widgetBuilderNewDesign: boolean): Record { const isDescending = query.orderby.startsWith('-'); const orderbyAggregateAliasField = query.orderby.replace('-', ''); - const prevAggregateAliasFieldStrings = query.aggregates.map(getAggregateAlias); + const prevAggregateAliasFieldStrings = query.aggregates.map(aggregate => + state.dataSet === DataSet.RELEASE ? aggregate : getAggregateAlias(aggregate) + ); const newQuery = cloneDeep(query); if (isColumn) { @@ -908,6 +915,7 @@ function WidgetBuilder({ dataSet={state.dataSet} displayType={state.displayType} onChange={handleDataSetChange} + widgetBuilderNewDesign={widgetBuilderNewDesign} /> {isTabularChart && ( ([]); - - useEffect(() => { - fetchTags(); - }, [projectIds]); - - async function fetchTags() { - try { - const response = await api.requestPromise( - `/organizations/${orgSlug}/metrics/tags/`, - { - query: { - project: !projectIds.length ? undefined : projectIds, - }, - } - ); - - setTags(response); - } catch { - addErrorMessage(t('Unable to fetch search bar tags')); - } - } + const {metricTags} = useMetricTags(); /** * Prepare query string (e.g. strip special characters like negation operator) @@ -79,7 +56,7 @@ function MetricsSearchBar({ ); } - const supportedTags = tags.reduce((acc, {key}) => { + const supportedTags = Object.values(metricTags).reduce((acc, {key}) => { acc[key] = {key, name: key}; return acc; }, {}); diff --git a/tests/js/spec/views/dashboardsV2/widgetBuilder/widgetBuilder.spec.tsx b/tests/js/spec/views/dashboardsV2/widgetBuilder/widgetBuilder.spec.tsx index 2be7d1786e13eb..654a2a829a9c2b 100644 --- a/tests/js/spec/views/dashboardsV2/widgetBuilder/widgetBuilder.spec.tsx +++ b/tests/js/spec/views/dashboardsV2/widgetBuilder/widgetBuilder.spec.tsx @@ -172,6 +172,16 @@ describe('WidgetBuilder', function () { url: '/organizations/org-slug/users/', body: [], }); + + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/metrics/tags/', + body: [], + }); + + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/metrics/meta/', + body: [], + }); }); afterEach(function () { From 2bbbb27c9d0731f5a1b013c6a6ac677627ed74e4 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Sun, 3 Apr 2022 14:20:45 +0200 Subject: [PATCH 02/12] add tests --- static/app/stores/metricsMetaStore.tsx | 35 +++++------ static/app/stores/metricsTagStore.tsx | 36 ++++++------ .../metricsTagsAndMetasContextContainer.tsx | 50 ++++++++++++++++ static/app/utils/useMetricMetas.tsx | 36 ++---------- static/app/utils/useMetricTags.tsx | 35 ++--------- static/app/views/dashboardsV2/dashboard.tsx | 4 +- .../columnsStep/releaseColumnFields.tsx | 2 + .../widgetBuilder/widgetBuilder.tsx | 20 +++++-- .../views/performance/metricsSearchBar.tsx | 2 + tests/js/spec/utils/useMetricMetas.spec.tsx | 49 ++++++++++++++++ tests/js/spec/utils/useMetricTags.spec.tsx | 38 ++++++++++++ .../widgetBuilder/widgetBuilder.spec.tsx | 58 +++++++++++++++++-- 12 files changed, 260 insertions(+), 105 deletions(-) create mode 100644 static/app/utils/metrics/metricsTagsAndMetasContextContainer.tsx create mode 100644 tests/js/spec/utils/useMetricMetas.spec.tsx create mode 100644 tests/js/spec/utils/useMetricTags.spec.tsx diff --git a/static/app/stores/metricsMetaStore.tsx b/static/app/stores/metricsMetaStore.tsx index 5173c9ccbbd90c..6fc616c9b6ce2b 100644 --- a/static/app/stores/metricsMetaStore.tsx +++ b/static/app/stores/metricsMetaStore.tsx @@ -7,26 +7,24 @@ import {makeSafeRefluxStore} from 'sentry/utils/makeSafeRefluxStore'; import {CommonStoreDefinition} from './types'; type State = { - isFetching: boolean; + /** + * This is state for when tags fetched from the API are loaded + */ + loaded: boolean; metricsMeta: MetricsMetaCollection; }; -type InternalDefinition = { - isFetching: boolean; - metricsMeta: MetricsMetaCollection; -}; - -interface MetricsMetaStoreDefinition - extends InternalDefinition, - CommonStoreDefinition { +interface MetricsMetaStoreDefinition extends CommonStoreDefinition { onLoadSuccess(data: MetricsMeta[]): void; reset(): void; } const storeConfig: MetricsMetaStoreDefinition = { unsubscribeListeners: [], - metricsMeta: {}, - isFetching: false, + state: { + metricsMeta: {}, + loaded: false, + }, init() { this.unsubscribeListeners.push( @@ -35,14 +33,15 @@ const storeConfig: MetricsMetaStoreDefinition = { }, reset() { - this.metricsMeta = {}; - this.isFetching = true; + this.state = { + metricsMeta: {}, + loaded: false, + }; this.trigger(this.state); }, getState() { - const {metricsMeta, isFetching} = this; - return {metricsMeta, isFetching}; + return this.state; }, onLoadSuccess(data) { @@ -54,8 +53,10 @@ const storeConfig: MetricsMetaStoreDefinition = { return acc; }, {}); - this.metricsMeta = {...this.metricsMeta, ...newFields}; - this.isFetching = false; + this.state = { + metricsMeta: {...this.state.metricsMeta, ...newFields}, + loaded: true, + }; this.trigger(this.state); }, }; diff --git a/static/app/stores/metricsTagStore.tsx b/static/app/stores/metricsTagStore.tsx index 99819eb5515954..e2167c0be83d90 100644 --- a/static/app/stores/metricsTagStore.tsx +++ b/static/app/stores/metricsTagStore.tsx @@ -7,26 +7,24 @@ import {makeSafeRefluxStore} from 'sentry/utils/makeSafeRefluxStore'; import {CommonStoreDefinition} from './types'; type State = { - isFetching: boolean; + /** + * This is state for when tags fetched from the API are loaded + */ + loaded: boolean; metricsTags: MetricsTagCollection; }; -type InternalDefinition = { - isFetching: boolean; - metricsTags: MetricsTagCollection; -}; - -interface MetricsTagStoreDefinition - extends InternalDefinition, - CommonStoreDefinition { +interface MetricsTagStoreDefinition extends CommonStoreDefinition { onLoadSuccess(data: MetricsTag[]): void; reset(): void; } const storeConfig: MetricsTagStoreDefinition = { unsubscribeListeners: [], - metricsTags: {}, - isFetching: false, + state: { + metricsTags: {}, + loaded: false, + }, init() { this.unsubscribeListeners.push( @@ -35,14 +33,15 @@ const storeConfig: MetricsTagStoreDefinition = { }, reset() { - this.metricsTags = {}; - this.isFetching = true; + this.state = { + metricsTags: {}, + loaded: false, + }; this.trigger(this.state); }, getState() { - const {metricsTags, isFetching} = this; - return {metricsTags, isFetching}; + return this.state; }, onLoadSuccess(data) { @@ -54,8 +53,11 @@ const storeConfig: MetricsTagStoreDefinition = { return acc; }, {}); - this.metricsTags = {...this.metricsTags, ...newTags}; - this.isFetching = false; + this.state = { + metricsTags: {...this.state.metricsTags, ...newTags}, + loaded: true, + }; + this.trigger(this.state); }, }; diff --git a/static/app/utils/metrics/metricsTagsAndMetasContextContainer.tsx b/static/app/utils/metrics/metricsTagsAndMetasContextContainer.tsx new file mode 100644 index 00000000000000..69b9b55ea639df --- /dev/null +++ b/static/app/utils/metrics/metricsTagsAndMetasContextContainer.tsx @@ -0,0 +1,50 @@ +import {createContext, useEffect, useMemo, useState} from 'react'; + +import {fetchMetricsFields, fetchMetricsTags} from 'sentry/actionCreators/metrics'; +import PageFiltersStore from 'sentry/stores/pageFiltersStore'; +import {useLegacyStore} from 'sentry/stores/useLegacyStore'; +import {MetricsMetaCollection, MetricsTagCollection} from 'sentry/types'; +import {} from 'sentry/types/debugFiles'; +import useApi from 'sentry/utils/useApi'; +import useOrganization from 'sentry/utils/useOrganization'; + +export interface MetricsTagsAndMetasContextProps { + metricsMetas: MetricsMetaCollection; + metricsTags: MetricsTagCollection; +} + +const MetricsTagsAndMetasContext = createContext({ + metricsTags: {}, + metricsMetas: {}, +}); + +type ProviderProps = { + children: React.ReactNode; +}; + +const MetricsTagsAndMetasContextContainer = ({children}: ProviderProps) => { + const api = useApi(); + const organization = useOrganization(); + const {selection} = useLegacyStore(PageFiltersStore); + + useEffect(() => { + let unmounted = false; + + if (!unmounted) { + fetchMetricsTags(api, organization.slug, selection.projects); + fetchMetricsFields(api, organization.slug, selection.projects); + } + + return () => { + unmounted = true; + }; + }, [organization.slug, selection.projects]); + + return ( + + {children} + + ); +}; + +export {MetricsTagsAndMetasContextContainer, MetricsTagsAndMetasContext}; diff --git a/static/app/utils/useMetricMetas.tsx b/static/app/utils/useMetricMetas.tsx index 6bc5e0e529a55f..ad2ef79c4b8e97 100644 --- a/static/app/utils/useMetricMetas.tsx +++ b/static/app/utils/useMetricMetas.tsx @@ -1,11 +1,9 @@ import {useEffect} from 'react'; -import {addErrorMessage} from 'sentry/actionCreators/indicator'; -import {t} from 'sentry/locale'; +import {fetchMetricsFields} from 'sentry/actionCreators/metrics'; import MetricsMetaStore from 'sentry/stores/metricsMetaStore'; import PageFiltersStore from 'sentry/stores/pageFiltersStore'; import {useLegacyStore} from 'sentry/stores/useLegacyStore'; -import handleXhrErrorResponse from 'sentry/utils/handleXhrErrorResponse'; import useApi from 'sentry/utils/useApi'; import useOrganization from './useOrganization'; @@ -14,42 +12,20 @@ export function useMetricMetas() { const api = useApi(); const organization = useOrganization(); const {selection} = useLegacyStore(PageFiltersStore); - const {metricsMeta, isFetching} = useLegacyStore(MetricsMetaStore); + const {metricsMeta, loaded} = useLegacyStore(MetricsMetaStore); + const shouldFetchMetricsMeta = !loaded; useEffect(() => { let unmounted = false; - if (!unmounted) { - fetchMetricMetas(); + if (!unmounted && shouldFetchMetricsMeta) { + fetchMetricsFields(api, organization.slug, selection.projects); } return () => { unmounted = true; }; - }, [selection.projects, organization.slug]); - - async function fetchMetricMetas() { - if (isFetching) { - return; - } - - MetricsMetaStore.reset(); - try { - const metas = await api.requestPromise( - `/organizations/${organization.slug}/metrics/meta/`, - { - query: { - project: selection.projects, - }, - } - ); - MetricsMetaStore.onLoadSuccess(metas); - } catch (error) { - const errorResponse = error?.responseJSON ?? t('Unable to fetch metric metas'); - addErrorMessage(errorResponse); - handleXhrErrorResponse(errorResponse)(error); - } - } + }, [selection.projects, organization.slug, shouldFetchMetricsMeta]); return {metricMetas: metricsMeta}; } diff --git a/static/app/utils/useMetricTags.tsx b/static/app/utils/useMetricTags.tsx index bdbf5c5c240ae2..d0ad8a13690967 100644 --- a/static/app/utils/useMetricTags.tsx +++ b/static/app/utils/useMetricTags.tsx @@ -1,11 +1,9 @@ import {useEffect} from 'react'; -import {addErrorMessage} from 'sentry/actionCreators/indicator'; -import {t} from 'sentry/locale'; +import {fetchMetricsTags} from 'sentry/actionCreators/metrics'; import MetricsTagStore from 'sentry/stores/metricsTagStore'; import PageFiltersStore from 'sentry/stores/pageFiltersStore'; import {useLegacyStore} from 'sentry/stores/useLegacyStore'; -import handleXhrErrorResponse from 'sentry/utils/handleXhrErrorResponse'; import useApi from 'sentry/utils/useApi'; import useOrganization from './useOrganization'; @@ -14,41 +12,20 @@ export function useMetricTags() { const api = useApi(); const organization = useOrganization(); const {selection} = useLegacyStore(PageFiltersStore); - const {metricsTags, isFetching} = useLegacyStore(MetricsTagStore); + const {metricsTags, loaded} = useLegacyStore(MetricsTagStore); + const shouldFetchMetricTags = !loaded; useEffect(() => { let unmounted = false; - if (!unmounted) { - fetchMetricTags(); + if (!unmounted && shouldFetchMetricTags) { + fetchMetricsTags(api, organization.slug, selection.projects); } return () => { unmounted = true; }; - }, [selection.projects, organization.slug]); - - async function fetchMetricTags() { - if (isFetching) { - return; - } - MetricsTagStore.reset(); - try { - const tags = await api.requestPromise( - `/organizations/${organization.slug}/metrics/tags/`, - { - query: { - project: selection.projects, - }, - } - ); - MetricsTagStore.onLoadSuccess(tags); - } catch (error) { - const errorResponse = error?.responseJSON ?? t('Unable to fetch metric tags'); - addErrorMessage(errorResponse); - handleXhrErrorResponse(errorResponse)(error); - } - } + }, [selection.projects, organization.slug, shouldFetchMetricTags]); return {metricTags: metricsTags}; } diff --git a/static/app/views/dashboardsV2/dashboard.tsx b/static/app/views/dashboardsV2/dashboard.tsx index c0666e27d7987c..5f0d5a166ca119 100644 --- a/static/app/views/dashboardsV2/dashboard.tsx +++ b/static/app/views/dashboardsV2/dashboard.tsx @@ -154,7 +154,7 @@ class Dashboard extends Component { organization.features.includes('new-widget-builder-experience-modal-access') ) { fetchMetricsFields(api, organization.slug, selection.projects); - // fetchMetricsTags(api, organization.slug, selection.projects); + fetchMetricsTags(api, organization.slug, selection.projects); } // Load organization tags when in edit mode. if (isEditing) { @@ -189,7 +189,7 @@ class Dashboard extends Component { organization.features.includes('new-widget-builder-experience-modal-access') ) { fetchMetricsFields(api, organization.slug, selection.projects); - // fetchMetricsTags(api, organization.slug, selection.projects); + fetchMetricsTags(api, organization.slug, selection.projects); } } } diff --git a/static/app/views/dashboardsV2/widgetBuilder/buildSteps/columnsStep/releaseColumnFields.tsx b/static/app/views/dashboardsV2/widgetBuilder/buildSteps/columnsStep/releaseColumnFields.tsx index a524524f12eeed..e801e5ba6b0657 100644 --- a/static/app/views/dashboardsV2/widgetBuilder/buildSteps/columnsStep/releaseColumnFields.tsx +++ b/static/app/views/dashboardsV2/widgetBuilder/buildSteps/columnsStep/releaseColumnFields.tsx @@ -7,6 +7,7 @@ import { } from 'sentry/utils/discover/fields'; import {useMetricMetas} from 'sentry/utils/useMetricMetas'; import {useMetricTags} from 'sentry/utils/useMetricTags'; +import useProjects from 'sentry/utils/useProjects'; import {DisplayType, WidgetType} from 'sentry/views/dashboardsV2/types'; import {generateMetricsWidgetFieldOptions} from 'sentry/views/dashboardsV2/widgetBuilder/metricWidget/fields'; import {FieldValueOption} from 'sentry/views/eventsV2/table/queryField'; @@ -33,6 +34,7 @@ export function ReleaseColumnFields({ }: Props) { const {metricTags} = useMetricTags(); const {metricMetas} = useMetricMetas(); + const {} = useProjects(); // Any function/field choice for Big Number widgets is legal since the // data source is from an endpoint that is not timeseries-based. // The function/field choice for World Map widget will need to be numeric-like. diff --git a/static/app/views/dashboardsV2/widgetBuilder/widgetBuilder.tsx b/static/app/views/dashboardsV2/widgetBuilder/widgetBuilder.tsx index 7dfb1ecc80af0d..b1fbbc5a4e7ddb 100644 --- a/static/app/views/dashboardsV2/widgetBuilder/widgetBuilder.tsx +++ b/static/app/views/dashboardsV2/widgetBuilder/widgetBuilder.tsx @@ -9,6 +9,7 @@ import set from 'lodash/set'; import {validateWidget} from 'sentry/actionCreators/dashboards'; import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; import {fetchOrgMembers} from 'sentry/actionCreators/members'; +import {fetchMetricsFields, fetchMetricsTags} from 'sentry/actionCreators/metrics'; import {generateOrderOptions} from 'sentry/components/dashboards/widgetQueriesForm'; import * as Layout from 'sentry/components/layouts/thirds'; import List from 'sentry/components/list'; @@ -240,12 +241,6 @@ function WidgetBuilder({ const [widgetToBeUpdated, setWidgetToBeUpdated] = useState(null); - useEffect(() => { - if (notDashboardsOrigin) { - fetchDashboards(); - } - }, [source]); - useEffect(() => { trackAdvancedAnalyticsEvent('dashboards_views.widget_builder.opened', { organization, @@ -281,10 +276,23 @@ function WidgetBuilder({ } }, []); + useEffect(() => { + if (notDashboardsOrigin) { + fetchDashboards(); + } + }, [source]); + useEffect(() => { fetchOrgMembers(api, organization.slug, selection.projects?.map(String)); }, [selection.projects]); + useEffect(() => { + if (widgetBuilderNewDesign) { + fetchMetricsTags(api, organization.slug, selection.projects); + fetchMetricsFields(api, organization.slug, selection.projects); + } + }, [selection.projects, organization.slug, widgetBuilderNewDesign]); + const widgetType = state.dataSet === DataSet.EVENTS ? WidgetType.DISCOVER diff --git a/static/app/views/performance/metricsSearchBar.tsx b/static/app/views/performance/metricsSearchBar.tsx index cfc088347bc0a0..f076334fa16fd4 100644 --- a/static/app/views/performance/metricsSearchBar.tsx +++ b/static/app/views/performance/metricsSearchBar.tsx @@ -6,6 +6,7 @@ import {NEGATION_OPERATOR, SEARCH_WILDCARD} from 'sentry/constants'; import {MetricsTagValue, Organization, Tag} from 'sentry/types'; import useApi from 'sentry/utils/useApi'; import {useMetricTags} from 'sentry/utils/useMetricTags'; +import useProjects from 'sentry/utils/useProjects'; const SEARCH_SPECIAL_CHARS_REGEXP = new RegExp( `^${NEGATION_OPERATOR}|\\${SEARCH_WILDCARD}`, @@ -33,6 +34,7 @@ function MetricsSearchBar({ }: Props) { const api = useApi(); const {metricTags} = useMetricTags(); + const {} = useProjects(); /** * Prepare query string (e.g. strip special characters like negation operator) diff --git a/tests/js/spec/utils/useMetricMetas.spec.tsx b/tests/js/spec/utils/useMetricMetas.spec.tsx new file mode 100644 index 00000000000000..2f60d269efa318 --- /dev/null +++ b/tests/js/spec/utils/useMetricMetas.spec.tsx @@ -0,0 +1,49 @@ +import {render, screen} from 'sentry-test/reactTestingLibrary'; + +import MetricsMetaStore from 'sentry/stores/metricsMetaStore'; +import {useMetricMetas} from 'sentry/utils/useMetricMetas'; + +function TestComponent({other}: {other: string}) { + const {metricMetas} = useMetricMetas(); + return ( +
+ {other} + {metricMetas && + Object.entries(metricMetas).map(([key, meta]) => {meta.name})} +
+ ); +} + +describe('useMetricMetas', function () { + it('works', async function () { + const organization = TestStubs.Organization(); + + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/metrics/meta/`, + body: [ + { + name: 'sentry.sessions.session', + type: 'counter', + operations: ['sum'], + }, + { + name: 'sentry.sessions.session.error', + type: 'set', + operations: ['count_unique'], + }, + ], + }); + + jest.spyOn(MetricsMetaStore, 'trigger'); + + render(, {organization}); + + // Should forward props. + expect(screen.getByText('value')).toBeInTheDocument(); + + expect(MetricsMetaStore.trigger).toHaveBeenCalledTimes(1); + + expect(await screen.findByText('sentry.sessions.session')).toBeInTheDocument(); + expect(screen.getByText('sentry.sessions.session.error')).toBeInTheDocument(); + }); +}); diff --git a/tests/js/spec/utils/useMetricTags.spec.tsx b/tests/js/spec/utils/useMetricTags.spec.tsx new file mode 100644 index 00000000000000..c416a08217273d --- /dev/null +++ b/tests/js/spec/utils/useMetricTags.spec.tsx @@ -0,0 +1,38 @@ +import {render, screen} from 'sentry-test/reactTestingLibrary'; + +import MetricsTagStore from 'sentry/stores/metricsTagStore'; +import {useMetricTags} from 'sentry/utils/useMetricTags'; + +function TestComponent({other}: {other: string}) { + const {metricTags} = useMetricTags(); + return ( +
+ {other} + {metricTags && + Object.entries(metricTags).map(([key, tag]) => {tag.key})} +
+ ); +} + +describe('useMetricTags', function () { + it('works', async function () { + const organization = TestStubs.Organization(); + + MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/metrics/tags/`, + body: [{key: 'environment'}, {key: 'release'}, {key: 'session.status'}], + }); + + jest.spyOn(MetricsTagStore, 'trigger'); + + render(, {organization}); + + // Should forward prop + expect(screen.getByText('value')).toBeInTheDocument(); + + expect(MetricsTagStore.trigger).toHaveBeenCalledTimes(1); + + // includes custom metricsTags + expect(await screen.findByText('session.status')).toBeInTheDocument(); + }); +}); diff --git a/tests/js/spec/views/dashboardsV2/widgetBuilder/widgetBuilder.spec.tsx b/tests/js/spec/views/dashboardsV2/widgetBuilder/widgetBuilder.spec.tsx index 654a2a829a9c2b..b4389ee5cf32cd 100644 --- a/tests/js/spec/views/dashboardsV2/widgetBuilder/widgetBuilder.spec.tsx +++ b/tests/js/spec/views/dashboardsV2/widgetBuilder/widgetBuilder.spec.tsx @@ -9,6 +9,7 @@ import {textWithMarkupMatcher} from 'sentry-test/utils'; import * as indicators from 'sentry/actionCreators/indicator'; import * as modals from 'sentry/actionCreators/modal'; import {TOP_N} from 'sentry/utils/discover/types'; +import {SessionMetric} from 'sentry/utils/metrics/fields'; import { DashboardDetails, DashboardWidgetSource, @@ -174,16 +175,35 @@ describe('WidgetBuilder', function () { }); MockApiClient.addMockResponse({ - url: '/organizations/org-slug/metrics/tags/', - body: [], + url: `/organizations/org-slug/metrics/tags/`, + body: [{key: 'environment'}, {key: 'release'}, {key: 'session.status'}], }); MockApiClient.addMockResponse({ - url: '/organizations/org-slug/metrics/meta/', - body: [], + url: `/organizations/org-slug/metrics/meta/`, + body: [ + { + name: SessionMetric.SESSION, + type: 'counter', + operations: ['sum'], + }, + { + name: SessionMetric.SESSION_ERROR, + type: 'set', + operations: ['count_unique'], + }, + ], }); }); + MockApiClient.addMockResponse({ + method: 'GET', + url: `/organizations/org-slug/metrics/data/`, + body: TestStubs.MetricsField({ + field: `sum(${SessionMetric.SESSION})`, + }), + }); + afterEach(function () { MockApiClient.clearMockResponses(); jest.clearAllMocks(); @@ -1554,6 +1574,36 @@ describe('WidgetBuilder', function () { }); }); + describe('Release Widgets', function () { + it('sets widgetType to release', async function () { + const handleSave = jest.fn(); + + renderTestComponent({ + onSave: handleSave, + orgFeatures: [...defaultOrgFeatures, 'new-widget-builder-experience-design'], + }); + + userEvent.click(await screen.findByText('Releases (sessions, crash rates)')); + userEvent.click(screen.getByLabelText('Add Widget')); + + await waitFor(() => { + expect(handleSave).toHaveBeenCalledWith([ + expect.objectContaining({ + widgetType: 'metrics', + queries: [ + expect.objectContaining({ + aggregates: ['sum(sentry.sessions.session)'], + fields: ['sum(sentry.sessions.session)'], + }), + ], + }), + ]); + }); + + expect(handleSave).toHaveBeenCalledTimes(1); + }); + }); + describe('Widget Library', function () { it('renders', async function () { renderTestComponent(); From f74b349ad9866b13612309fff112ff1426f16668 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Sun, 3 Apr 2022 14:23:00 +0200 Subject: [PATCH 03/12] remove not need file --- .../metricsTagsAndMetasContextContainer.tsx | 50 ------------------- 1 file changed, 50 deletions(-) delete mode 100644 static/app/utils/metrics/metricsTagsAndMetasContextContainer.tsx diff --git a/static/app/utils/metrics/metricsTagsAndMetasContextContainer.tsx b/static/app/utils/metrics/metricsTagsAndMetasContextContainer.tsx deleted file mode 100644 index 69b9b55ea639df..00000000000000 --- a/static/app/utils/metrics/metricsTagsAndMetasContextContainer.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import {createContext, useEffect, useMemo, useState} from 'react'; - -import {fetchMetricsFields, fetchMetricsTags} from 'sentry/actionCreators/metrics'; -import PageFiltersStore from 'sentry/stores/pageFiltersStore'; -import {useLegacyStore} from 'sentry/stores/useLegacyStore'; -import {MetricsMetaCollection, MetricsTagCollection} from 'sentry/types'; -import {} from 'sentry/types/debugFiles'; -import useApi from 'sentry/utils/useApi'; -import useOrganization from 'sentry/utils/useOrganization'; - -export interface MetricsTagsAndMetasContextProps { - metricsMetas: MetricsMetaCollection; - metricsTags: MetricsTagCollection; -} - -const MetricsTagsAndMetasContext = createContext({ - metricsTags: {}, - metricsMetas: {}, -}); - -type ProviderProps = { - children: React.ReactNode; -}; - -const MetricsTagsAndMetasContextContainer = ({children}: ProviderProps) => { - const api = useApi(); - const organization = useOrganization(); - const {selection} = useLegacyStore(PageFiltersStore); - - useEffect(() => { - let unmounted = false; - - if (!unmounted) { - fetchMetricsTags(api, organization.slug, selection.projects); - fetchMetricsFields(api, organization.slug, selection.projects); - } - - return () => { - unmounted = true; - }; - }, [organization.slug, selection.projects]); - - return ( - - {children} - - ); -}; - -export {MetricsTagsAndMetasContextContainer, MetricsTagsAndMetasContext}; From 3dd22a3ab7124edc70c8c10559683568057f3a7c Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Sun, 3 Apr 2022 14:28:06 +0200 Subject: [PATCH 04/12] add extra comment --- .../spec/views/dashboardsV2/widgetBuilder/widgetBuilder.spec.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/js/spec/views/dashboardsV2/widgetBuilder/widgetBuilder.spec.tsx b/tests/js/spec/views/dashboardsV2/widgetBuilder/widgetBuilder.spec.tsx index b4389ee5cf32cd..641565d85238b4 100644 --- a/tests/js/spec/views/dashboardsV2/widgetBuilder/widgetBuilder.spec.tsx +++ b/tests/js/spec/views/dashboardsV2/widgetBuilder/widgetBuilder.spec.tsx @@ -1589,6 +1589,7 @@ describe('WidgetBuilder', function () { await waitFor(() => { expect(handleSave).toHaveBeenCalledWith([ expect.objectContaining({ + // TODO(adam): Update widget type to be 'release' widgetType: 'metrics', queries: [ expect.objectContaining({ From c7c334f2467e54056c299e64ea6741b5e6e24c2e Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Sun, 3 Apr 2022 15:22:45 +0200 Subject: [PATCH 05/12] move client mock --- .../widgetBuilder/widgetBuilder.spec.tsx | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/tests/js/spec/views/dashboardsV2/widgetBuilder/widgetBuilder.spec.tsx b/tests/js/spec/views/dashboardsV2/widgetBuilder/widgetBuilder.spec.tsx index 641565d85238b4..4d4b42ee1aa913 100644 --- a/tests/js/spec/views/dashboardsV2/widgetBuilder/widgetBuilder.spec.tsx +++ b/tests/js/spec/views/dashboardsV2/widgetBuilder/widgetBuilder.spec.tsx @@ -194,14 +194,20 @@ describe('WidgetBuilder', function () { }, ], }); - }); - MockApiClient.addMockResponse({ - method: 'GET', - url: `/organizations/org-slug/metrics/data/`, - body: TestStubs.MetricsField({ - field: `sum(${SessionMetric.SESSION})`, - }), + MockApiClient.addMockResponse({ + method: 'GET', + url: `/organizations/org-slug/metrics/data/`, + body: TestStubs.MetricsField({ + field: `sum(${SessionMetric.SESSION})`, + }), + match: [ + MockApiClient.matchQuery({ + groupBy: [], + orderBy: `sum(${SessionMetric.SESSION})`, + }), + ], + }); }); afterEach(function () { From 79a57bcd3f691b73359eb8412aa63ceb15228791 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Sun, 3 Apr 2022 16:44:28 +0200 Subject: [PATCH 06/12] fix test --- tests/js/spec/components/dashboards/widgetQueriesForm.spec.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/js/spec/components/dashboards/widgetQueriesForm.spec.tsx b/tests/js/spec/components/dashboards/widgetQueriesForm.spec.tsx index 8a029831a5190e..953f196d38f36e 100644 --- a/tests/js/spec/components/dashboards/widgetQueriesForm.spec.tsx +++ b/tests/js/spec/components/dashboards/widgetQueriesForm.spec.tsx @@ -131,7 +131,8 @@ describe('WidgetQueriesForm', function () { orderby: field, }, ]} - /> + />, + {organization} ); userEvent.click(screen.getByText('sum(sentry.sessions.session) asc')); expect(screen.getByText('sum(sentry.sessions.session) desc')).toBeInTheDocument(); From 378ae5bba3c532b47b668c3a62245bacac21dbfc Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Sun, 3 Apr 2022 18:15:24 +0200 Subject: [PATCH 07/12] ref(new-widget-builder-experience): Add support to release data set in sortby --- .../dashboards/widgetQueriesForm.tsx | 2 +- .../buildSteps/filterResultsStep/index.tsx | 60 ++++++++-------- .../filterResultsStep/issuesSearchBar.tsx | 4 +- .../buildSteps/sortByStep/index.tsx | 2 +- .../dashboardsV2/widgetBuilder/utils.tsx | 10 +-- .../widgetBuilder/widgetBuilder.tsx | 15 ++-- .../views/performance/metricsSearchBar.tsx | 2 - .../widgetBuilder/widgetBuilder.spec.tsx | 68 +++++++++++++++++-- 8 files changed, 112 insertions(+), 51 deletions(-) diff --git a/static/app/components/dashboards/widgetQueriesForm.tsx b/static/app/components/dashboards/widgetQueriesForm.tsx index 45255b59b8ed72..fb4274a20857b4 100644 --- a/static/app/components/dashboards/widgetQueriesForm.tsx +++ b/static/app/components/dashboards/widgetQueriesForm.tsx @@ -53,7 +53,7 @@ export const generateOrderOptions = ({ } if (widgetBuilderNewDesign) { - options.push({label, value: alias}); + options.push({label, value: isMetrics ? field : alias}); return; } diff --git a/static/app/views/dashboardsV2/widgetBuilder/buildSteps/filterResultsStep/index.tsx b/static/app/views/dashboardsV2/widgetBuilder/buildSteps/filterResultsStep/index.tsx index 9edcdc0fa89109..b115861de29436 100644 --- a/static/app/views/dashboardsV2/widgetBuilder/buildSteps/filterResultsStep/index.tsx +++ b/static/app/views/dashboardsV2/widgetBuilder/buildSteps/filterResultsStep/index.tsx @@ -53,38 +53,45 @@ export function FilterResultsStep({ }; }, []); - const handleSearch = useCallback((queryIndex: number) => { - return (field: string) => { - // SearchBar will call handlers for both onSearch and onBlur - // when selecting a value from the autocomplete dropdown. This can - // cause state issues for the search bar in our use case. To prevent - // this, we set a timer in our onSearch handler to block our onBlur - // handler from firing if it is within 200ms, ie from clicking an - // autocomplete value. - blurTimeoutRef.current = window.setTimeout(() => { - blurTimeoutRef.current = null; - }, 200); - - const newQuery: WidgetQuery = { - ...queries[queryIndex], - conditions: field, - }; - - onQueryChange(queryIndex, newQuery); - }; - }, []); + const handleSearch = useCallback( + (queryIndex: number) => { + return (field: string) => { + // SearchBar will call handlers for both onSearch and onBlur + // when selecting a value from the autocomplete dropdown. This can + // cause state issues for the search bar in our use case. To prevent + // this, we set a timer in our onSearch handler to block our onBlur + // handler from firing if it is within 200ms, ie from clicking an + // autocomplete value. + blurTimeoutRef.current = window.setTimeout(() => { + blurTimeoutRef.current = null; + }, 200); - const handleBlur = useCallback((queryIndex: number) => { - return (field: string) => { - if (!blurTimeoutRef.current) { const newQuery: WidgetQuery = { ...queries[queryIndex], conditions: field, }; + onQueryChange(queryIndex, newQuery); - } - }; - }, []); + }; + }, + [queries] + ); + + const handleBlur = useCallback( + (queryIndex: number) => { + return (field: string) => { + if (!blurTimeoutRef.current) { + const newQuery: WidgetQuery = { + ...queries[queryIndex], + conditions: field, + }; + + onQueryChange(queryIndex, newQuery); + } + }; + }, + [queries] + ); return ( ) : widgetType === WidgetType.DISCOVER ? ( { const newState = cloneDeep(prevState); - const query = cloneDeep(getDataSetQuery(widgetBuilderNewDesign)[DataSet.EVENTS]); + const query = cloneDeep(getDataSetQuery(widgetBuilderNewDesign)[prevState.dataSet]); query.fields = prevState.queries[0].fields; query.aggregates = prevState.queries[0].aggregates; query.columns = prevState.queries[0].columns; diff --git a/static/app/views/performance/metricsSearchBar.tsx b/static/app/views/performance/metricsSearchBar.tsx index f076334fa16fd4..cfc088347bc0a0 100644 --- a/static/app/views/performance/metricsSearchBar.tsx +++ b/static/app/views/performance/metricsSearchBar.tsx @@ -6,7 +6,6 @@ import {NEGATION_OPERATOR, SEARCH_WILDCARD} from 'sentry/constants'; import {MetricsTagValue, Organization, Tag} from 'sentry/types'; import useApi from 'sentry/utils/useApi'; import {useMetricTags} from 'sentry/utils/useMetricTags'; -import useProjects from 'sentry/utils/useProjects'; const SEARCH_SPECIAL_CHARS_REGEXP = new RegExp( `^${NEGATION_OPERATOR}|\\${SEARCH_WILDCARD}`, @@ -34,7 +33,6 @@ function MetricsSearchBar({ }: Props) { const api = useApi(); const {metricTags} = useMetricTags(); - const {} = useProjects(); /** * Prepare query string (e.g. strip special characters like negation operator) diff --git a/tests/js/spec/views/dashboardsV2/widgetBuilder/widgetBuilder.spec.tsx b/tests/js/spec/views/dashboardsV2/widgetBuilder/widgetBuilder.spec.tsx index 4d4b42ee1aa913..c2de11564a7989 100644 --- a/tests/js/spec/views/dashboardsV2/widgetBuilder/widgetBuilder.spec.tsx +++ b/tests/js/spec/views/dashboardsV2/widgetBuilder/widgetBuilder.spec.tsx @@ -26,6 +26,9 @@ const defaultOrgFeatures = [ 'global-views', ]; +// Mocking worldMapChart to avoid act warnings +jest.mock('sentry/components/charts/worldMapChart'); + function renderTestComponent({ dashboard, query, @@ -179,6 +182,16 @@ describe('WidgetBuilder', function () { body: [{key: 'environment'}, {key: 'release'}, {key: 'session.status'}], }); + MockApiClient.addMockResponse({ + url: `/organizations/org-slug/metrics/tags/session.status/`, + body: [ + { + key: 'session.status', + value: 'crashed', + }, + ], + }); + MockApiClient.addMockResponse({ url: `/organizations/org-slug/metrics/meta/`, body: [ @@ -201,12 +214,6 @@ describe('WidgetBuilder', function () { body: TestStubs.MetricsField({ field: `sum(${SessionMetric.SESSION})`, }), - match: [ - MockApiClient.matchQuery({ - groupBy: [], - orderBy: `sum(${SessionMetric.SESSION})`, - }), - ], }); }); @@ -1580,7 +1587,7 @@ describe('WidgetBuilder', function () { }); }); - describe('Release Widgets', function () { + describe.only('Release Widgets', function () { it('sets widgetType to release', async function () { const handleSave = jest.fn(); @@ -1601,6 +1608,7 @@ describe('WidgetBuilder', function () { expect.objectContaining({ aggregates: ['sum(sentry.sessions.session)'], fields: ['sum(sentry.sessions.session)'], + orderby: '-sum(sentry.sessions.session)', }), ], }), @@ -1609,6 +1617,52 @@ describe('WidgetBuilder', function () { expect(handleSave).toHaveBeenCalledTimes(1); }); + + it('render release data set disabled when the display type is world map', async function () { + renderTestComponent({ + query: { + source: DashboardWidgetSource.DISCOVERV2, + }, + orgFeatures: [...defaultOrgFeatures, 'new-widget-builder-experience-design'], + }); + + userEvent.click(await screen.findByText('Table')); + userEvent.click(screen.getByText('World Map')); + expect( + screen.getByRole('radio', { + name: 'Select Events (Errors, transactions)', + }) + ).toBeEnabled(); + expect( + screen.getByRole('radio', { + name: 'Select Issues (Status, assignee, etc.)', + }) + ).toBeDisabled(); + expect( + screen.getByRole('radio', { + name: 'Select Releases (sessions, crash rates)', + }) + ).toBeDisabled(); + }); + + it('renders with an release search bar', async function () { + renderTestComponent({ + orgFeatures: [...defaultOrgFeatures, 'new-widget-builder-experience-design'], + }); + + userEvent.type( + await screen.findByPlaceholderText('Search for events, users, tags, and more'), + 'session.status:' + ); + expect(await screen.findByText('No items found')).toBeInTheDocument(); + + userEvent.click(screen.getByText('Releases (sessions, crash rates)')); + userEvent.type( + screen.getByPlaceholderText('Search for events, users, tags, and more'), + 'session.status:' + ); + expect(await screen.findByText('crashed')).toBeInTheDocument(); + }); }); describe('Widget Library', function () { From 009a3f54cff7c1e1e588940326d983f570521874 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Sun, 3 Apr 2022 18:21:02 +0200 Subject: [PATCH 08/12] remove focused test --- static/app/views/dashboardsV2/widgetBuilder/widgetBuilder.tsx | 2 +- .../views/dashboardsV2/widgetBuilder/widgetBuilder.spec.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/static/app/views/dashboardsV2/widgetBuilder/widgetBuilder.tsx b/static/app/views/dashboardsV2/widgetBuilder/widgetBuilder.tsx index aaf11748fc32d7..f2cae6da30ff11 100644 --- a/static/app/views/dashboardsV2/widgetBuilder/widgetBuilder.tsx +++ b/static/app/views/dashboardsV2/widgetBuilder/widgetBuilder.tsx @@ -91,7 +91,7 @@ function getDataSetQuery(widgetBuilderNewDesign: boolean): Record Date: Sun, 3 Apr 2022 18:22:02 +0200 Subject: [PATCH 09/12] remove useProjects --- static/app/views/performance/metricsSearchBar.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/static/app/views/performance/metricsSearchBar.tsx b/static/app/views/performance/metricsSearchBar.tsx index f076334fa16fd4..cfc088347bc0a0 100644 --- a/static/app/views/performance/metricsSearchBar.tsx +++ b/static/app/views/performance/metricsSearchBar.tsx @@ -6,7 +6,6 @@ import {NEGATION_OPERATOR, SEARCH_WILDCARD} from 'sentry/constants'; import {MetricsTagValue, Organization, Tag} from 'sentry/types'; import useApi from 'sentry/utils/useApi'; import {useMetricTags} from 'sentry/utils/useMetricTags'; -import useProjects from 'sentry/utils/useProjects'; const SEARCH_SPECIAL_CHARS_REGEXP = new RegExp( `^${NEGATION_OPERATOR}|\\${SEARCH_WILDCARD}`, @@ -34,7 +33,6 @@ function MetricsSearchBar({ }: Props) { const api = useApi(); const {metricTags} = useMetricTags(); - const {} = useProjects(); /** * Prepare query string (e.g. strip special characters like negation operator) From f5de69d1b16df6dd79880bcc8c27dc59fd883af1 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Mon, 4 Apr 2022 10:04:12 +0200 Subject: [PATCH 10/12] fix test --- .../spec/components/modals/addDashboardWidgetModal.spec.jsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/js/spec/components/modals/addDashboardWidgetModal.spec.jsx b/tests/js/spec/components/modals/addDashboardWidgetModal.spec.jsx index 8cb9162714cb33..57ba0be5478ddf 100644 --- a/tests/js/spec/components/modals/addDashboardWidgetModal.spec.jsx +++ b/tests/js/spec/components/modals/addDashboardWidgetModal.spec.jsx @@ -68,7 +68,10 @@ function mountModalWithRtl({initialData, onAddWidget, onUpdateWidget, widget, so widget={widget} closeModal={() => void 0} source={source || types.DashboardWidgetSource.DASHBOARDS} - /> + />, + { + organization: initialData.organization, + } ); } From 3e9e943b129fa751e54b3719a4adf3629687b31c Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Mon, 4 Apr 2022 11:28:37 +0200 Subject: [PATCH 11/12] disable test CI --- .../views/dashboardsV2/widgetBuilder/widgetBuilder.spec.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/js/spec/views/dashboardsV2/widgetBuilder/widgetBuilder.spec.tsx b/tests/js/spec/views/dashboardsV2/widgetBuilder/widgetBuilder.spec.tsx index 7fb32857f37698..528e3e33c7bd4b 100644 --- a/tests/js/spec/views/dashboardsV2/widgetBuilder/widgetBuilder.spec.tsx +++ b/tests/js/spec/views/dashboardsV2/widgetBuilder/widgetBuilder.spec.tsx @@ -1645,7 +1645,9 @@ describe('WidgetBuilder', function () { ).toBeDisabled(); }); - it('renders with an release search bar', async function () { + // Disabling for CI, but should run locally when making changes + // eslint-disable-next-line jest/no-disabled-tests + it.skip('renders with an release search bar', async function () { renderTestComponent({ orgFeatures: [...defaultOrgFeatures, 'new-widget-builder-experience-design'], }); From 368422636329a12b15284e96d7e23c0f0be29491 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Wed, 6 Apr 2022 08:45:59 +0200 Subject: [PATCH 12/12] cr feedback --- .../widgetBuilder/buildSteps/columnsStep/index.tsx | 12 ++++++------ .../buildSteps/filterResultsStep/index.tsx | 1 + .../buildSteps/filterResultsStep/issuesSearchBar.tsx | 4 +++- .../widgetBuilder/buildSteps/sortByStep/index.tsx | 10 +++++----- 4 files changed, 15 insertions(+), 12 deletions(-) diff --git a/static/app/views/dashboardsV2/widgetBuilder/buildSteps/columnsStep/index.tsx b/static/app/views/dashboardsV2/widgetBuilder/buildSteps/columnsStep/index.tsx index c52b532ce41b32..be44938805c356 100644 --- a/static/app/views/dashboardsV2/widgetBuilder/buildSteps/columnsStep/index.tsx +++ b/static/app/views/dashboardsV2/widgetBuilder/buildSteps/columnsStep/index.tsx @@ -47,21 +47,21 @@ export function ColumnsStep({ - ), tagFieldLink: ( ), } ) : tct( - '[tagFieldLink: Tag and field] columns will help you view more details about the issues (i.e. title).', + 'To group events, add [functionLink: functions] f(x) that may take in additional parameters. [tagFieldLink: Tag and field] columns will help you view more details about the events (i.e. title).', { + functionLink: ( + + ), tagFieldLink: ( ), diff --git a/static/app/views/dashboardsV2/widgetBuilder/buildSteps/filterResultsStep/index.tsx b/static/app/views/dashboardsV2/widgetBuilder/buildSteps/filterResultsStep/index.tsx index dd3569d6b2036c..83a4b1f58feaca 100644 --- a/static/app/views/dashboardsV2/widgetBuilder/buildSteps/filterResultsStep/index.tsx +++ b/static/app/views/dashboardsV2/widgetBuilder/buildSteps/filterResultsStep/index.tsx @@ -115,6 +115,7 @@ export function FilterResultsStep({ {widgetType === WidgetType.ISSUE ? (