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..6fc616c9b6ce2b 100644 --- a/static/app/stores/metricsMetaStore.tsx +++ b/static/app/stores/metricsMetaStore.tsx @@ -7,23 +7,24 @@ import {makeSafeRefluxStore} from 'sentry/utils/makeSafeRefluxStore'; import {CommonStoreDefinition} from './types'; type State = { + /** + * This is state for when tags fetched from the API are loaded + */ + loaded: boolean; metricsMeta: MetricsMetaCollection; }; -type InternalDefinition = { - metricsMeta: MetricsMetaCollection; -}; - -interface MetricsMetaStoreDefinition - extends InternalDefinition, - CommonStoreDefinition { +interface MetricsMetaStoreDefinition extends CommonStoreDefinition { onLoadSuccess(data: MetricsMeta[]): void; reset(): void; } const storeConfig: MetricsMetaStoreDefinition = { unsubscribeListeners: [], - metricsMeta: {}, + state: { + metricsMeta: {}, + loaded: false, + }, init() { this.unsubscribeListeners.push( @@ -32,13 +33,15 @@ const storeConfig: MetricsMetaStoreDefinition = { }, reset() { - this.metricsMeta = {}; - this.trigger(this.metricsMeta); + this.state = { + metricsMeta: {}, + loaded: false, + }; + this.trigger(this.state); }, getState() { - const {metricsMeta} = this; - return {metricsMeta}; + return this.state; }, onLoadSuccess(data) { @@ -50,8 +53,11 @@ const storeConfig: MetricsMetaStoreDefinition = { return acc; }, {}); - this.metricsMeta = {...this.metricsMeta, ...newFields}; - this.trigger(this.metricsMeta); + 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 006c2a0d0497da..e2167c0be83d90 100644 --- a/static/app/stores/metricsTagStore.tsx +++ b/static/app/stores/metricsTagStore.tsx @@ -7,23 +7,24 @@ import {makeSafeRefluxStore} from 'sentry/utils/makeSafeRefluxStore'; import {CommonStoreDefinition} from './types'; type State = { + /** + * This is state for when tags fetched from the API are loaded + */ + loaded: boolean; metricsTags: MetricsTagCollection; }; -type InternalDefinition = { - metricsTags: MetricsTagCollection; -}; - -interface MetricsTagStoreDefinition - extends InternalDefinition, - CommonStoreDefinition { +interface MetricsTagStoreDefinition extends CommonStoreDefinition { onLoadSuccess(data: MetricsTag[]): void; reset(): void; } const storeConfig: MetricsTagStoreDefinition = { unsubscribeListeners: [], - metricsTags: {}, + state: { + metricsTags: {}, + loaded: false, + }, init() { this.unsubscribeListeners.push( @@ -32,13 +33,15 @@ const storeConfig: MetricsTagStoreDefinition = { }, reset() { - this.metricsTags = {}; - this.trigger(this.metricsTags); + this.state = { + metricsTags: {}, + loaded: false, + }; + this.trigger(this.state); }, getState() { - const {metricsTags} = this; - return {metricsTags}; + return this.state; }, onLoadSuccess(data) { @@ -50,8 +53,12 @@ const storeConfig: MetricsTagStoreDefinition = { return acc; }, {}); - this.metricsTags = {...this.metricsTags, ...newTags}; - this.trigger(this.metricsTags); + this.state = { + metricsTags: {...this.state.metricsTags, ...newTags}, + loaded: true, + }; + + this.trigger(this.state); }, }; diff --git a/static/app/utils/useMetricMetas.tsx b/static/app/utils/useMetricMetas.tsx new file mode 100644 index 00000000000000..ad2ef79c4b8e97 --- /dev/null +++ b/static/app/utils/useMetricMetas.tsx @@ -0,0 +1,31 @@ +import {useEffect} from 'react'; + +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 useApi from 'sentry/utils/useApi'; + +import useOrganization from './useOrganization'; + +export function useMetricMetas() { + const api = useApi(); + const organization = useOrganization(); + const {selection} = useLegacyStore(PageFiltersStore); + const {metricsMeta, loaded} = useLegacyStore(MetricsMetaStore); + const shouldFetchMetricsMeta = !loaded; + + useEffect(() => { + let unmounted = false; + + if (!unmounted && shouldFetchMetricsMeta) { + fetchMetricsFields(api, organization.slug, selection.projects); + } + + return () => { + unmounted = true; + }; + }, [selection.projects, organization.slug, shouldFetchMetricsMeta]); + + return {metricMetas: metricsMeta}; +} diff --git a/static/app/utils/useMetricTags.tsx b/static/app/utils/useMetricTags.tsx new file mode 100644 index 00000000000000..d0ad8a13690967 --- /dev/null +++ b/static/app/utils/useMetricTags.tsx @@ -0,0 +1,31 @@ +import {useEffect} from 'react'; + +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 useApi from 'sentry/utils/useApi'; + +import useOrganization from './useOrganization'; + +export function useMetricTags() { + const api = useApi(); + const organization = useOrganization(); + const {selection} = useLegacyStore(PageFiltersStore); + const {metricsTags, loaded} = useLegacyStore(MetricsTagStore); + const shouldFetchMetricTags = !loaded; + + useEffect(() => { + let unmounted = false; + + if (!unmounted && shouldFetchMetricTags) { + fetchMetricsTags(api, organization.slug, selection.projects); + } + + return () => { + unmounted = true; + }; + }, [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 aec003f3152964..5f0d5a166ca119 100644 --- a/static/app/views/dashboardsV2/dashboard.tsx +++ b/static/app/views/dashboardsV2/dashboard.tsx @@ -148,7 +148,11 @@ 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); } @@ -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..e801e5ba6b0657 --- /dev/null +++ b/static/app/views/dashboardsV2/widgetBuilder/buildSteps/columnsStep/releaseColumnFields.tsx @@ -0,0 +1,81 @@ +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 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'; +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(); + 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. + // 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..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'; @@ -101,7 +102,7 @@ function getDataSetQuery(widgetBuilderNewDesign: boolean): Record(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 @@ -342,9 +350,11 @@ function WidgetBuilder({ } if ( - prevState.displayType === DisplayType.TABLE && - widgetToBeUpdated?.widgetType && - WIDGET_TYPE_TO_DATA_SET[widgetToBeUpdated.widgetType] === DataSet.ISSUES + (prevState.displayType === DisplayType.TABLE && + widgetToBeUpdated?.widgetType && + WIDGET_TYPE_TO_DATA_SET[widgetToBeUpdated.widgetType] === DataSet.ISSUES) || + (prevState.dataSet === DataSet.RELEASE && + newDisplayType === DisplayType.WORLD_MAP) ) { // World Map display type only supports Events Dataset // so set state to default events query. @@ -500,7 +510,10 @@ function WidgetBuilder({ isColumn = false ) { const fieldStrings = newFields.map(generateFieldAsString); - const aggregateAliasFieldStrings = fieldStrings.map(getAggregateAlias); + const aggregateAliasFieldStrings = + state.dataSet === DataSet.RELEASE + ? fieldStrings + : fieldStrings.map(getAggregateAlias); const columnsAndAggregates = isColumn ? getColumnsAndAggregatesAsStrings(newFields) @@ -511,7 +524,9 @@ function WidgetBuilder({ const newQueries = state.queries.map(query => { 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 +923,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/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(); 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, + } ); } 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 2be7d1786e13eb..4d4b42ee1aa913 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, @@ -172,6 +173,41 @@ describe('WidgetBuilder', function () { url: '/organizations/org-slug/users/', body: [], }); + + MockApiClient.addMockResponse({ + url: `/organizations/org-slug/metrics/tags/`, + body: [{key: 'environment'}, {key: 'release'}, {key: 'session.status'}], + }); + + MockApiClient.addMockResponse({ + 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})`, + }), + match: [ + MockApiClient.matchQuery({ + groupBy: [], + orderBy: `sum(${SessionMetric.SESSION})`, + }), + ], + }); }); afterEach(function () { @@ -1544,6 +1580,37 @@ 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({ + // TODO(adam): Update widget type to be 'release' + 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();