From 2c07d9115b069fa9b4380a597e70db7cdb52bbd6 Mon Sep 17 00:00:00 2001 From: k-fish Date: Wed, 20 Oct 2021 18:02:30 -0400 Subject: [PATCH 1/3] feat(perf): Add trends widgets for landing page v3 This adds trends widgets in the new generic performance widget format. Clicking on the transaction will bring you to the trends page scope to that transaction, which should help discovery of trends as a feature. Made some modifications to the trends chart and query to support alternate use, and moved a few shared functions. Other: - Did some light fixes for multi-breakpoints, will need to do more still since it's not great between wide screen and phone size. --- .../trends/trendsDiscoverQuery.tsx | 12 +- static/app/views/performance/content.tsx | 52 +---- .../app/views/performance/landing/utils.tsx | 20 ++ .../landing/views/allTransactionsView.tsx | 3 +- .../widgets/components/performanceWidget.tsx | 11 +- .../widgets/components/selectableList.tsx | 6 +- .../widgets/components/widgetContainer.tsx | 3 +- .../transforms/transformTrendsDiscover.tsx | 21 ++ .../performance/landing/widgets/types.tsx | 2 + .../widgets/widgets/lineChartListWidget.tsx | 38 ++-- .../landing/widgets/widgets/trendsWidget.tsx | 197 ++++++++++++++++++ .../trends/changedTransactions.tsx | 6 +- static/app/views/performance/trends/chart.tsx | 47 +++-- static/app/views/performance/trends/utils.tsx | 8 +- static/app/views/performance/utils.tsx | 67 +++++- .../views/performance/landing/index.spec.tsx | 8 +- .../landing/widgets/widgetContainer.spec.jsx | 84 ++++++++ 17 files changed, 481 insertions(+), 104 deletions(-) create mode 100644 static/app/views/performance/landing/widgets/transforms/transformTrendsDiscover.tsx create mode 100644 static/app/views/performance/landing/widgets/widgets/trendsWidget.tsx diff --git a/static/app/utils/performance/trends/trendsDiscoverQuery.tsx b/static/app/utils/performance/trends/trendsDiscoverQuery.tsx index 8ef90e27943f60..38ae036c903f5b 100644 --- a/static/app/utils/performance/trends/trendsDiscoverQuery.tsx +++ b/static/app/utils/performance/trends/trendsDiscoverQuery.tsx @@ -7,6 +7,7 @@ import GenericDiscoverQuery, { import withApi from 'app/utils/withApi'; import { TrendChangeType, + TrendFunctionField, TrendsData, TrendsDataEvents, TrendsQuery, @@ -20,12 +21,13 @@ import { export type TrendsRequest = { trendChangeType?: TrendChangeType; + trendFunctionField?: TrendFunctionField; eventView: Partial; }; type RequestProps = DiscoverQueryProps & TrendsRequest; -type ChildrenProps = Omit, 'tableData'> & { +export type ChildrenProps = Omit, 'tableData'> & { trendsData: TrendsData | null; }; @@ -44,13 +46,13 @@ type EventProps = RequestProps & { export function getTrendsRequestPayload(props: RequestProps) { const {eventView} = props; const apiPayload: TrendsQuery = eventView?.getEventsAPIPayload(props.location); - const trendFunction = getCurrentTrendFunction(props.location); + const trendFunction = getCurrentTrendFunction(props.location, props.trendFunctionField); const trendParameter = getCurrentTrendParameter(props.location); apiPayload.trendFunction = generateTrendFunctionAsString( trendFunction.field, trendParameter.column ); - apiPayload.trendType = eventView?.trendType; + apiPayload.trendType = eventView?.trendType || props.trendChangeType; apiPayload.interval = eventView?.interval; apiPayload.middle = eventView?.middle; return apiPayload; @@ -59,9 +61,9 @@ export function getTrendsRequestPayload(props: RequestProps) { function TrendsDiscoverQuery(props: Props) { return ( + {...props} route="events-trends-stats" getRequestPayload={getTrendsRequestPayload} - {...props} > {({tableData, ...rest}) => { return props.children({trendsData: tableData, ...rest}); @@ -73,9 +75,9 @@ function TrendsDiscoverQuery(props: Props) { function EventsDiscoverQuery(props: EventProps) { return ( + {...props} route="events-trends" getRequestPayload={getTrendsRequestPayload} - {...props} > {({tableData, ...rest}) => { return props.children({trendsData: tableData, ...rest}); diff --git a/static/app/views/performance/content.tsx b/static/app/views/performance/content.tsx index 79be7520c0dfc5..d287d52ac18bc1 100644 --- a/static/app/views/performance/content.tsx +++ b/static/app/views/performance/content.tsx @@ -21,19 +21,16 @@ import {GlobalSelection, Organization, Project} from 'app/types'; import {trackAnalyticsEvent} from 'app/utils/analytics'; import EventView from 'app/utils/discover/eventView'; import {PerformanceEventViewProvider} from 'app/utils/performance/contexts/performanceEventViewContext'; -import {decodeScalar} from 'app/utils/queryString'; -import {MutableSearch} from 'app/utils/tokenizeSearch'; import withApi from 'app/utils/withApi'; import withGlobalSelection from 'app/utils/withGlobalSelection'; import withOrganization from 'app/utils/withOrganization'; import withProjects from 'app/utils/withProjects'; import LandingContent from './landing/content'; -import {DEFAULT_MAX_DURATION} from './trends/utils'; import {DEFAULT_STATS_PERIOD, generatePerformanceEventView} from './data'; import {PerformanceLanding} from './landing'; import Onboarding from './onboarding'; -import {addRoutePerformanceContext, getPerformanceTrendsUrl} from './utils'; +import {addRoutePerformanceContext, handleTrendsClick} from './utils'; type Props = { api: Client; @@ -131,49 +128,6 @@ class PerformanceContent extends Component { }); }; - handleTrendsClick = () => { - const {location, organization} = this.props; - - const newQuery = { - ...location.query, - }; - - const query = decodeScalar(location.query.query, ''); - const conditions = new MutableSearch(query); - - trackAnalyticsEvent({ - eventKey: 'performance_views.change_view', - eventName: 'Performance Views: Change View', - organization_id: parseInt(organization.id, 10), - view_name: 'TRENDS', - }); - - const modifiedConditions = new MutableSearch([]); - - if (conditions.hasFilter('tpm()')) { - modifiedConditions.setFilterValues('tpm()', conditions.getFilterValues('tpm()')); - } else { - modifiedConditions.setFilterValues('tpm()', ['>0.01']); - } - if (conditions.hasFilter('transaction.duration')) { - modifiedConditions.setFilterValues( - 'transaction.duration', - conditions.getFilterValues('transaction.duration') - ); - } else { - modifiedConditions.setFilterValues('transaction.duration', [ - '>0', - `<${DEFAULT_MAX_DURATION}`, - ]); - } - newQuery.query = modifiedConditions.formatString(); - - browserHistory.push({ - pathname: getPerformanceTrendsUrl(organization), - query: {...newQuery}, - }); - }; - shouldShowOnboarding() { const {projects, demoMode} = this.props; const {eventView} = this.state; @@ -218,7 +172,7 @@ class PerformanceContent extends Component { @@ -259,7 +213,7 @@ class PerformanceContent extends Component { eventView={this.state.eventView} setError={this.setError} handleSearch={this.handleSearch} - handleTrendsClick={this.handleTrendsClick} + handleTrendsClick={() => handleTrendsClick(this.props)} shouldShowOnboarding={this.shouldShowOnboarding()} {...this.props} /> diff --git a/static/app/views/performance/landing/utils.tsx b/static/app/views/performance/landing/utils.tsx index 24fc83fe9b16f8..7ec0c426f3121e 100644 --- a/static/app/views/performance/landing/utils.tsx +++ b/static/app/views/performance/landing/utils.tsx @@ -1,3 +1,4 @@ +import {ReactText} from 'react'; import {browserHistory} from 'react-router'; import {Location} from 'history'; @@ -60,6 +61,25 @@ export const LANDING_DISPLAYS = [ }, ]; +export function excludeTransaction( + transaction: string | ReactText, + props: {eventView: EventView; location: Location} +) { + const {eventView, location} = props; + + const searchConditions = new MutableSearch(eventView.query); + searchConditions.addFilterValues('!transaction', [`${transaction}`]); + + browserHistory.push({ + pathname: location.pathname, + query: { + ...location.query, + cursor: undefined, + query: searchConditions.formatString(), + }, + }); +} + export function getCurrentLandingDisplay( location: Location, projects: Project[], diff --git a/static/app/views/performance/landing/views/allTransactionsView.tsx b/static/app/views/performance/landing/views/allTransactionsView.tsx index d33aa984fccf67..89c0bc1fd54daa 100644 --- a/static/app/views/performance/landing/views/allTransactionsView.tsx +++ b/static/app/views/performance/landing/views/allTransactionsView.tsx @@ -24,9 +24,10 @@ export function AllTransactionsView(props: BasePerformanceViewProps) { diff --git a/static/app/views/performance/landing/widgets/components/performanceWidget.tsx b/static/app/views/performance/landing/widgets/components/performanceWidget.tsx index c0e8348e9ca9d2..0ca8129338052e 100644 --- a/static/app/views/performance/landing/widgets/components/performanceWidget.tsx +++ b/static/app/views/performance/landing/widgets/components/performanceWidget.tsx @@ -68,7 +68,7 @@ function _DataDisplay( props: GenericPerformanceWidgetProps & WidgetDataProps & {nextWidgetData: T; totalHeight: number} ) { - const {Visualizations, chartHeight, totalHeight, containerType} = props; + const {Visualizations, chartHeight, totalHeight, containerType, EmptyComponent} = props; const Container = getPerformanceWidgetContainer({ containerType, @@ -111,7 +111,14 @@ function _DataDisplay( /> ))} - emptyComponent={} + loadingComponent={} + emptyComponent={ + EmptyComponent ? ( + + ) : ( + + ) + } /> ); diff --git a/static/app/views/performance/landing/widgets/components/selectableList.tsx b/static/app/views/performance/landing/widgets/components/selectableList.tsx index 50ea77d6b3c357..fa2710da5c572e 100644 --- a/static/app/views/performance/landing/widgets/components/selectableList.tsx +++ b/static/app/views/performance/landing/widgets/components/selectableList.tsx @@ -53,10 +53,8 @@ export const RightAlignedCell = styled('div')` `; const ListItemContainer = styled('div')` - display: grid; - grid-template-columns: 24px auto 150px 30px; - grid-template-rows: repeat(2, auto); - grid-column-gap: ${space(1)}; + display: flex; + border-top: 1px solid ${p => p.theme.border}; padding: ${space(1)} ${space(2)}; `; diff --git a/static/app/views/performance/landing/widgets/components/widgetContainer.tsx b/static/app/views/performance/landing/widgets/components/widgetContainer.tsx index 0b645619c6207c..282d1c5b88a549 100644 --- a/static/app/views/performance/landing/widgets/components/widgetContainer.tsx +++ b/static/app/views/performance/landing/widgets/components/widgetContainer.tsx @@ -12,6 +12,7 @@ import {GenericPerformanceWidgetDataType} from '../types'; import {PerformanceWidgetSetting, WIDGET_DEFINITIONS} from '../widgetDefinitions'; import {LineChartListWidget} from '../widgets/lineChartListWidget'; import {SingleFieldAreaWidget} from '../widgets/singleFieldAreaWidget'; +import {TrendsWidget} from '../widgets/trendsWidget'; import {ChartRowProps} from './widgetChartRow'; @@ -89,7 +90,7 @@ const _WidgetContainer = (props: Props) => { switch (widgetProps.dataType) { case GenericPerformanceWidgetDataType.trends: - throw new Error('Trends not currently supported.'); + return ; case GenericPerformanceWidgetDataType.area: return ; case GenericPerformanceWidgetDataType.line_list: diff --git a/static/app/views/performance/landing/widgets/transforms/transformTrendsDiscover.tsx b/static/app/views/performance/landing/widgets/transforms/transformTrendsDiscover.tsx new file mode 100644 index 00000000000000..bdc9823d1aa2ef --- /dev/null +++ b/static/app/views/performance/landing/widgets/transforms/transformTrendsDiscover.tsx @@ -0,0 +1,21 @@ +import {ChildrenProps} from 'app/utils/performance/trends/trendsDiscoverQuery'; +import {normalizeTrends} from 'app/views/performance/trends/utils'; + +export function transformTrendsDiscover(_: any, props: ChildrenProps) { + const {trendsData} = props; + const events = trendsData + ? normalizeTrends((trendsData && trendsData.events && trendsData.events.data) || []) + : []; + return { + ...props, + data: trendsData, + hasData: !!trendsData?.events?.data.length, + loading: props.isLoading, + isLoading: props.isLoading, + isErrored: !!props.error, + errored: props.error, + statsData: trendsData ? trendsData.stats : {}, + transactionsList: events && events.slice ? events.slice(0, 3) : [], + events, + }; +} diff --git a/static/app/views/performance/landing/widgets/types.tsx b/static/app/views/performance/landing/widgets/types.tsx index 42b38a5216b5d0..a71bce61d8845e 100644 --- a/static/app/views/performance/landing/widgets/types.tsx +++ b/static/app/views/performance/landing/widgets/types.tsx @@ -110,6 +110,8 @@ export type GenericPerformanceWidgetProps = { // Components HeaderActions?: HeaderActions; + EmptyComponent?: FunctionComponent<{height?: number}>; + Queries: Queries; Visualizations: Visualizations; }; diff --git a/static/app/views/performance/landing/widgets/widgets/lineChartListWidget.tsx b/static/app/views/performance/landing/widgets/widgets/lineChartListWidget.tsx index 5c54e8c23ba4f1..ae81598274417a 100644 --- a/static/app/views/performance/landing/widgets/widgets/lineChartListWidget.tsx +++ b/static/app/views/performance/landing/widgets/widgets/lineChartListWidget.tsx @@ -1,5 +1,5 @@ -import {Fragment, FunctionComponent, ReactText, useMemo, useState} from 'react'; -import {browserHistory, withRouter} from 'react-router'; +import {Fragment, FunctionComponent, useMemo, useState} from 'react'; +import {withRouter} from 'react-router'; import styled from '@emotion/styled'; import {Location} from 'history'; @@ -7,8 +7,10 @@ import _EventsRequest from 'app/components/charts/eventsRequest'; import {getInterval} from 'app/components/charts/utils'; import Link from 'app/components/links/link'; import Tooltip from 'app/components/tooltip'; +import Truncate from 'app/components/truncate'; import {IconClose} from 'app/icons'; import {t} from 'app/locale'; +import space from 'app/styles/space'; import {Organization} from 'app/types'; import DiscoverQuery from 'app/utils/discover/discoverQuery'; import EventView from 'app/utils/discover/eventView'; @@ -17,6 +19,7 @@ import withApi from 'app/utils/withApi'; import _DurationChart from 'app/views/performance/charts/chart'; import {transactionSummaryRouteWithQuery} from 'app/views/performance/transactionSummary/utils'; +import {excludeTransaction} from '../../utils'; import {GenericPerformanceWidget} from '../components/performanceWidget'; import SelectableList, {RightAlignedCell} from '../components/selectableList'; import {transformDiscoverToList} from '../transforms/transformDiscoverToList'; @@ -43,22 +46,6 @@ type DataType = { list: WidgetDataResult & ReturnType; }; -function excludeTransaction(transaction: string | ReactText, props: Props) { - const {eventView, location} = props; - - const searchConditions = new MutableSearch(eventView.query); - searchConditions.addFilterValues('!transaction', [`${transaction}`]); - - browserHistory.push({ - pathname: location.pathname, - query: { - ...location.query, - cursor: undefined, - query: searchConditions.formatString(), - }, - }); -} - export function LineChartListWidget(props: Props) { const [selectedListIndex, setSelectListIndex] = useState(0); const {ContainerActions} = props; @@ -190,17 +177,20 @@ export function LineChartListWidget(props: Props) { selectedIndex={selectedListIndex} setSelectedIndex={setSelectListIndex} items={provided.widgetData.list.data.map(listItem => () => { + const transaction = listItem.transaction as string; const transactionTarget = transactionSummaryRouteWithQuery({ orgSlug: props.organization.slug, projectID: listItem['project.id'] as string, - transaction: listItem.transaction as string, + transaction, query: props.eventView.getGlobalSelectionQuery(), }); switch (props.chartSetting) { case PerformanceWidgetSetting.MOST_RELATED_ISSUES: return ( - {listItem.transaction} + + + - {listItem.transaction} + + + {listItem.failure_count} ; +}; + +type TrendsWidgetDataType = { + chart: WidgetDataResult & ReturnType; +}; + +const fields = [{field: 'transaction'}, {field: 'project'}]; + +export function TrendsWidget(props: Props) { + const {eventView: _eventView, ContainerActions} = props; + const trendChangeType = + props.chartSetting === PerformanceWidgetSetting.MOST_IMPROVED + ? TrendChangeType.IMPROVED + : TrendChangeType.REGRESSION; + const trendFunctionField = TrendFunctionField.AVG; // Average is the easiest chart to understand. + + const [selectedListIndex, setSelectListIndex] = useState(0); + + const eventView = _eventView.clone(); + eventView.fields = fields; + eventView.sorts = [ + { + kind: trendChangeType === TrendChangeType.IMPROVED ? 'asc' : 'desc', + field: 'trend_percentage()', + }, + ]; + const rest = {eventView, ...omit(props, 'eventView')}; + eventView.additionalConditions.addFilterValues('tpm()', ['>0.01']); + eventView.additionalConditions.addFilterValues('count_percentage()', ['>0.25', '<4']); + eventView.additionalConditions.addFilterValues('trend_percentage()', ['>0%']); + eventView.additionalConditions.addFilterValues('confidence()', ['>6']); + + const Queries = useMemo(() => { + return { + chart: { + fields: ['transaction', 'project'], + component: provided => ( + + ), + transform: transformTrendsDiscover, + }, + }; + }, [_eventView, trendChangeType]); + + return ( + + {...rest} + subtitle={{t('Trending Transactions')}} + HeaderActions={provided => } + Queries={Queries} + Visualizations={[ + { + component: provided => ( + + ), + bottomPadding: false, + height: 160, + }, + { + component: provided => ( + () => { + const initialConditions = new MutableSearch([]); + initialConditions.addFilterValues('transaction', [listItem.transaction]); + + const trendsTarget = trendsTargetRoute({ + organization: props.organization, + location: props.location, + initialConditions, + additionalQuery: { + trendFunction: trendFunctionField, + }, + }); + return ( + + + + + + + + + { + excludeTransaction(listItem.transaction, props); + setSelectListIndex(0); + }} + /> + + + ); + })} + /> + ), + height: 200, + noPadding: true, + }, + ]} + EmptyComponent={() => ( + {t('No results')} + )} + /> + ); +} + +const TrendsChart = withRouter(withProjects(Chart)); +const Subtitle = styled('span')` + color: ${p => p.theme.gray300}; + font-size: ${p => p.theme.fontSizeMedium}; +`; +const CloseContainer = styled('div')` + display: flex; + align-items: center; + justify-content: center; +`; +const GrowLink = styled(Link)` + flex-grow: 1; +`; + +const StyledIconClose = styled(IconClose)` + cursor: pointer; + color: ${p => p.theme.gray200}; + + &:hover { + color: ${p => p.theme.gray300}; + } +`; + +const StyledEmptyStateWarning = styled(EmptyStateWarning)` + min-height: 300px; + justify-content: center; +`; diff --git a/static/app/views/performance/trends/changedTransactions.tsx b/static/app/views/performance/trends/changedTransactions.tsx index ae1cfa4fd48eab..abe1bc828eb15f 100644 --- a/static/app/views/performance/trends/changedTransactions.tsx +++ b/static/app/views/performance/trends/changedTransactions.tsx @@ -486,7 +486,11 @@ function TrendsListItem(props: TrendsListItemProps) { ); } -const CompareDurations = ({transaction}: TrendsListItemProps) => { +export const CompareDurations = ({ + transaction, +}: { + transaction: TrendsListItemProps['transaction']; +}) => { const {fromSeconds, toSeconds, showDigits} = transformDeltaSpread( transaction.aggregate_range_1, transaction.aggregate_range_2 diff --git a/static/app/views/performance/trends/chart.tsx b/static/app/views/performance/trends/chart.tsx index ad03b43a0b5252..9aef3912b58d0b 100644 --- a/static/app/views/performance/trends/chart.tsx +++ b/static/app/views/performance/trends/chart.tsx @@ -17,7 +17,12 @@ import getDynamicText from 'app/utils/getDynamicText'; import {decodeList} from 'app/utils/queryString'; import {Theme} from 'app/utils/theme'; -import {NormalizedTrendsTransaction, TrendChangeType, TrendsStats} from './types'; +import { + NormalizedTrendsTransaction, + TrendChangeType, + TrendFunctionField, + TrendsStats, +} from './types'; import { generateTrendFunctionAsString, getCurrentTrendFunction, @@ -43,10 +48,15 @@ type Props = WithRouterProps & location: Location; organization: OrganizationSummary; trendChangeType: TrendChangeType; + trendFunctionField?: TrendFunctionField; transaction?: NormalizedTrendsTransaction; isLoading: boolean; statsData: TrendsStats; projects: Project[]; + height?: number; + grid?: LineChart['props']['grid']; + disableXAxis?: boolean; + disableLegend?: boolean; }; function transformEventStats(data: EventsStatsData, seriesName?: string): Series[] { @@ -222,7 +232,7 @@ function getIntervalLine( return additionalLineSeries; } -function Chart({ +export function Chart({ trendChangeType, router, statsPeriod, @@ -235,6 +245,11 @@ function Chart({ projects, start: propsStart, end: propsEnd, + trendFunctionField, + disableXAxis, + disableLegend, + grid, + height, }: Props) { const theme = useTheme(); @@ -264,7 +279,7 @@ function Chart({ : undefined; const data = events?.data ?? []; - const trendFunction = getCurrentTrendFunction(location); + const trendFunction = getCurrentTrendFunction(location, trendFunctionField); const trendParameter = getCurrentTrendParameter(location); const chartLabel = generateTrendFunctionAsString( trendFunction.field, @@ -286,10 +301,12 @@ function Chart({ selection[metric] = false; return selection; }, {}); - const legend = { - ...getLegend(chartLabel), - selected: seriesSelection, - }; + const legend = disableLegend + ? {show: false} + : { + ...getLegend(chartLabel), + selected: seriesSelection, + }; const loading = isLoading; const reloading = isLoading; @@ -379,6 +396,7 @@ function Chart({ {getDynamicText({ value: ( ), fixed: 'Duration Chart', diff --git a/static/app/views/performance/trends/utils.tsx b/static/app/views/performance/trends/utils.tsx index c6d97caa23117d..5bd0295f226077 100644 --- a/static/app/views/performance/trends/utils.tsx +++ b/static/app/views/performance/trends/utils.tsx @@ -135,8 +135,12 @@ export function resetCursors() { return cursors; } -export function getCurrentTrendFunction(location: Location): TrendFunction { - const trendFunctionField = decodeScalar(location?.query?.trendFunction); +export function getCurrentTrendFunction( + location: Location, + _trendFunctionField?: TrendFunctionField +): TrendFunction { + const trendFunctionField = + _trendFunctionField ?? decodeScalar(location?.query?.trendFunction); const trendFunction = TRENDS_FUNCTIONS.find(({field}) => field === trendFunctionField); return trendFunction || TRENDS_FUNCTIONS[0]; } diff --git a/static/app/views/performance/utils.tsx b/static/app/views/performance/utils.tsx index d7a7df4b8e452d..2c37f7e3591f0e 100644 --- a/static/app/views/performance/utils.tsx +++ b/static/app/views/performance/utils.tsx @@ -1,10 +1,12 @@ +import {browserHistory} from 'react-router'; import {Location, LocationDescriptor, Query} from 'history'; import Duration from 'app/components/duration'; import {ALL_ACCESS_PROJECTS} from 'app/constants/globalSelectionHeader'; import {backend, frontend, mobile} from 'app/data/platformCategories'; -import {GlobalSelection, OrganizationSummary, Project} from 'app/types'; +import {GlobalSelection, Organization, OrganizationSummary, Project} from 'app/types'; import {defined} from 'app/utils'; +import {trackAnalyticsEvent} from 'app/utils/analytics'; import {statsPeriodToDays} from 'app/utils/dates'; import EventView from 'app/utils/discover/eventView'; import {TRACING_FIELDS} from 'app/utils/discover/fields'; @@ -13,6 +15,8 @@ import getCurrentSentryReactTransaction from 'app/utils/getCurrentSentryReactTra import {decodeScalar} from 'app/utils/queryString'; import {MutableSearch} from 'app/utils/tokenizeSearch'; +import {DEFAULT_MAX_DURATION} from './trends/utils'; + /** * Performance type can used to determine a default view or which specific field should be used by default on pages * where we don't want to wait for transaction data to return to determine how to display aspects of a page. @@ -117,6 +121,67 @@ export function getTransactionSearchQuery(location: Location, query: string = '' return decodeScalar(location.query.query, query).trim(); } +export function handleTrendsClick({ + location, + organization, +}: { + location: Location; + organization: Organization; +}) { + trackAnalyticsEvent({ + eventKey: 'performance_views.change_view', + eventName: 'Performance Views: Change View', + organization_id: parseInt(organization.id, 10), + view_name: 'TRENDS', + }); + + const target = trendsTargetRoute({location, organization}); + + browserHistory.push(target); +} + +export function trendsTargetRoute({ + location, + organization, + initialConditions, + additionalQuery, +}: { + location: Location; + organization: Organization; + initialConditions?: MutableSearch; + additionalQuery?: {[x: string]: string}; +}) { + const newQuery = { + ...location.query, + ...additionalQuery, + }; + + const query = decodeScalar(location.query.query, ''); + const conditions = new MutableSearch(query); + + const modifiedConditions = initialConditions ?? new MutableSearch([]); + + if (conditions.hasFilter('tpm()')) { + modifiedConditions.setFilterValues('tpm()', conditions.getFilterValues('tpm()')); + } else { + modifiedConditions.setFilterValues('tpm()', ['>0.01']); + } + if (conditions.hasFilter('transaction.duration')) { + modifiedConditions.setFilterValues( + 'transaction.duration', + conditions.getFilterValues('transaction.duration') + ); + } else { + modifiedConditions.setFilterValues('transaction.duration', [ + '>0', + `<${DEFAULT_MAX_DURATION}`, + ]); + } + newQuery.query = modifiedConditions.formatString(); + + return {pathname: getPerformanceTrendsUrl(organization), query: {...newQuery}}; +} + export function removeTracingKeysFromSearch( currentFilter: MutableSearch, options: {excludeTagKeys: Set} = { diff --git a/tests/js/spec/views/performance/landing/index.spec.tsx b/tests/js/spec/views/performance/landing/index.spec.tsx index 32e81a80fc1df9..271d8eaa8c125e 100644 --- a/tests/js/spec/views/performance/landing/index.spec.tsx +++ b/tests/js/spec/views/performance/landing/index.spec.tsx @@ -157,8 +157,8 @@ describe('Performance > Landing > Index', function () { expect(wrapper.find('Table').exists()).toBe(true); - expect(eventStatsMock).toHaveBeenCalledTimes(4); // Currently defaulting to 4 event stat charts on all transactions view + 1 event chart. - expect(eventsV2Mock).toHaveBeenCalledTimes(1); + expect(eventStatsMock).toHaveBeenCalledTimes(3); // Currently defaulting to 4 event stat charts on all transactions view + 1 event chart. + expect(eventsV2Mock).toHaveBeenCalledTimes(2); const titles = wrapper.find('div[data-test-id="performance-widget-title"]'); expect(titles).toHaveLength(5); @@ -166,7 +166,7 @@ describe('Performance > Landing > Index', function () { expect(titles.at(0).text()).toEqual('User Misery'); expect(titles.at(1).text()).toEqual('Transactions Per Minute'); expect(titles.at(2).text()).toEqual('Failure Rate'); - expect(titles.at(3).text()).toEqual('Transactions Per Minute'); - expect(titles.at(4).text()).toEqual('Most Related Errors'); + expect(titles.at(3).text()).toEqual('Most Related Errors'); + expect(titles.at(4).text()).toEqual('Most Related Issues'); }); }); diff --git a/tests/js/spec/views/performance/landing/widgets/widgetContainer.spec.jsx b/tests/js/spec/views/performance/landing/widgets/widgetContainer.spec.jsx index 9d13ccf77cfe80..078e81950b631c 100644 --- a/tests/js/spec/views/performance/landing/widgets/widgetContainer.spec.jsx +++ b/tests/js/spec/views/performance/landing/widgets/widgetContainer.spec.jsx @@ -25,6 +25,7 @@ const WrappedComponent = ({data, ...rest}) => { describe('Performance > Widgets > WidgetContainer', function () { let eventStatsMock; let eventsV2Mock; + let eventsTrendsStats; beforeEach(function () { eventStatsMock = MockApiClient.addMockResponse({ method: 'GET', @@ -36,6 +37,11 @@ describe('Performance > Widgets > WidgetContainer', function () { url: `/organizations/org-slug/eventsv2/`, body: [], }); + eventsTrendsStats = MockApiClient.addMockResponse({ + method: 'GET', + url: '/organizations/org-slug/events-trends-stats/', + body: [], + }); }); it('TPM Widget', async function () { @@ -208,6 +214,84 @@ describe('Performance > Widgets > WidgetContainer', function () { ); }); + it('Most improved trends widget', async function () { + const data = initializeData(); + + const wrapper = mountWithTheme( + , + data.routerContext + ); + await tick(); + wrapper.update(); + + expect(wrapper.find('div[data-test-id="performance-widget-title"]').text()).toEqual( + 'Most Improved' + ); + expect(eventsTrendsStats).toHaveBeenCalledTimes(1); + expect(eventsTrendsStats).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.objectContaining({ + query: expect.objectContaining({ + environment: [], + field: ['transaction', 'project'], + interval: undefined, + middle: undefined, + per_page: 3, + project: [], + query: + 'tpm():>0.01 count_percentage():>0.25 count_percentage():<4 trend_percentage():>0% confidence():>6', + sort: 'trend_percentage()', + statsPeriod: '14d', + trendFunction: 'avg(transaction.duration)', + trendType: 'improved', + }), + }) + ); + }); + + it('Most regressed trends widget', async function () { + const data = initializeData(); + + const wrapper = mountWithTheme( + , + data.routerContext + ); + await tick(); + wrapper.update(); + + expect(wrapper.find('div[data-test-id="performance-widget-title"]').text()).toEqual( + 'Most Regressed' + ); + expect(eventsTrendsStats).toHaveBeenCalledTimes(1); + expect(eventsTrendsStats).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.objectContaining({ + query: expect.objectContaining({ + environment: [], + field: ['transaction', 'project'], + interval: undefined, + middle: undefined, + per_page: 3, + project: [], + query: + 'tpm():>0.01 count_percentage():>0.25 count_percentage():<4 trend_percentage():>0% confidence():>6', + sort: '-trend_percentage()', + statsPeriod: '14d', + trendFunction: 'avg(transaction.duration)', + trendType: 'regression', + }), + }) + ); + }); + it('Able to change widget type from menu', async function () { const data = initializeData(); From cc4ecb3d03a3af746b2c06d1afbdd21e8877caea Mon Sep 17 00:00:00 2001 From: k-fish Date: Thu, 21 Oct 2021 06:25:52 -0400 Subject: [PATCH 2/3] Use less generic name for children props of trend discovery. --- .../app/utils/performance/trends/trendsDiscoverQuery.tsx | 7 +++++-- .../landing/widgets/transforms/transformTrendsDiscover.tsx | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/static/app/utils/performance/trends/trendsDiscoverQuery.tsx b/static/app/utils/performance/trends/trendsDiscoverQuery.tsx index 38ae036c903f5b..3903fcb0e22993 100644 --- a/static/app/utils/performance/trends/trendsDiscoverQuery.tsx +++ b/static/app/utils/performance/trends/trendsDiscoverQuery.tsx @@ -27,12 +27,15 @@ export type TrendsRequest = { type RequestProps = DiscoverQueryProps & TrendsRequest; -export type ChildrenProps = Omit, 'tableData'> & { +export type TrendDiscoveryChildrenProps = Omit< + GenericChildrenProps, + 'tableData' +> & { trendsData: TrendsData | null; }; type Props = RequestProps & { - children: (props: ChildrenProps) => React.ReactNode; + children: (props: TrendDiscoveryChildrenProps) => React.ReactNode; }; type EventChildrenProps = Omit, 'tableData'> & { diff --git a/static/app/views/performance/landing/widgets/transforms/transformTrendsDiscover.tsx b/static/app/views/performance/landing/widgets/transforms/transformTrendsDiscover.tsx index bdc9823d1aa2ef..c4767f90cb749c 100644 --- a/static/app/views/performance/landing/widgets/transforms/transformTrendsDiscover.tsx +++ b/static/app/views/performance/landing/widgets/transforms/transformTrendsDiscover.tsx @@ -1,7 +1,7 @@ -import {ChildrenProps} from 'app/utils/performance/trends/trendsDiscoverQuery'; +import {TrendDiscoveryChildrenProps} from 'app/utils/performance/trends/trendsDiscoverQuery'; import {normalizeTrends} from 'app/views/performance/trends/utils'; -export function transformTrendsDiscover(_: any, props: ChildrenProps) { +export function transformTrendsDiscover(_: any, props: TrendDiscoveryChildrenProps) { const {trendsData} = props; const events = trendsData ? normalizeTrends((trendsData && trendsData.events && trendsData.events.data) || []) From fd72ab2b9c9708f76b7f7c17e9aedd8754079b2e Mon Sep 17 00:00:00 2001 From: k-fish Date: Thu, 21 Oct 2021 11:05:27 -0400 Subject: [PATCH 3/3] Address PR comment and remove omit --- .../views/performance/landing/widgets/widgets/trendsWidget.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/static/app/views/performance/landing/widgets/widgets/trendsWidget.tsx b/static/app/views/performance/landing/widgets/widgets/trendsWidget.tsx index c1ecb1fda2f747..9204839bc0e858 100644 --- a/static/app/views/performance/landing/widgets/widgets/trendsWidget.tsx +++ b/static/app/views/performance/landing/widgets/widgets/trendsWidget.tsx @@ -2,7 +2,6 @@ import {Fragment, FunctionComponent, useMemo, useState} from 'react'; import {withRouter} from 'react-router'; import styled from '@emotion/styled'; import {Location} from 'history'; -import omit from 'lodash/omit'; import EmptyStateWarning from 'app/components/emptyStateWarning'; import Link from 'app/components/links/link'; @@ -64,7 +63,7 @@ export function TrendsWidget(props: Props) { field: 'trend_percentage()', }, ]; - const rest = {eventView, ...omit(props, 'eventView')}; + const rest = {...props, eventView}; eventView.additionalConditions.addFilterValues('tpm()', ['>0.01']); eventView.additionalConditions.addFilterValues('count_percentage()', ['>0.25', '<4']); eventView.additionalConditions.addFilterValues('trend_percentage()', ['>0%']);