From 52c7d08d9c396f9778c557429f0e83074c64f2c3 Mon Sep 17 00:00:00 2001 From: Tony Xiao Date: Wed, 1 Sep 2021 13:13:17 -0400 Subject: [PATCH] ref(performance): Separate header from content in transaction events Continuing from #28343, this separates the header from the content on the transaction events tab. --- .../transactionEvents/content.tsx | 218 +++-------- .../transactionEvents/index.tsx | 345 +++++++++--------- .../performance/transactionSummary/types.tsx | 3 + .../performance/transactionEvents.spec.tsx | 13 +- .../transactionEvents/content.spec.tsx | 8 +- 5 files changed, 235 insertions(+), 352 deletions(-) create mode 100644 static/app/views/performance/transactionSummary/types.tsx diff --git a/static/app/views/performance/transactionSummary/transactionEvents/content.tsx b/static/app/views/performance/transactionSummary/transactionEvents/content.tsx index 5bb52feb4f2ad9..0401f8a2eedd3f 100644 --- a/static/app/views/performance/transactionSummary/transactionEvents/content.tsx +++ b/static/app/views/performance/transactionSummary/transactionEvents/content.tsx @@ -1,214 +1,95 @@ -import * as React from 'react'; -import {Fragment} from 'react'; import {browserHistory} from 'react-router'; import styled from '@emotion/styled'; import {Location} from 'history'; import omit from 'lodash/omit'; -import Alert from 'app/components/alert'; import Button from 'app/components/button'; -import {CreateAlertFromViewButton} from 'app/components/createAlertButton'; import DropdownControl, {DropdownItem} from 'app/components/dropdownControl'; import SearchBar from 'app/components/events/searchBar'; import GlobalSdkUpdateAlert from 'app/components/globalSdkUpdateAlert'; import * as Layout from 'app/components/layouts/thirds'; -import LoadingIndicator from 'app/components/loadingIndicator'; import {getParams} from 'app/components/organizations/globalSelectionHeader/getParams'; -import {IconFlag} from 'app/icons'; import {t} from 'app/locale'; import space from 'app/styles/space'; -import {Organization, Project} from 'app/types'; +import {Organization} from 'app/types'; import EventView from 'app/utils/discover/eventView'; import {WebVital} from 'app/utils/discover/fields'; import {decodeScalar} from 'app/utils/queryString'; -import {MutableSearch} from 'app/utils/tokenizeSearch'; -import {Actions, updateQuery} from 'app/views/eventsV2/table/cellAction'; -import {TableColumn} from 'app/views/eventsV2/table/types'; import Filter, {filterToSearchConditions, SpanOperationBreakdownFilter} from '../filter'; -import TransactionHeader from '../header'; -import Tab from '../tabs'; +import {SetStateAction} from '../types'; import EventsTable from './eventsTable'; import {EventsDisplayFilterName, getEventsFilterOptions} from './utils'; type Props = { location: Location; + organization: Organization; eventView: EventView; transactionName: string; - organization: Organization; - projects: Project[]; spanOperationBreakdownFilter: SpanOperationBreakdownFilter; onChangeSpanOperationBreakdownFilter: (newFilter: SpanOperationBreakdownFilter) => void; eventsDisplayFilterName: EventsDisplayFilterName; onChangeEventsDisplayFilter: (eventsDisplayFilterName: EventsDisplayFilterName) => void; percentileValues?: Record; - isLoading: boolean; webVital?: WebVital; + setError: SetStateAction; }; -type State = { - incompatibleAlertNotice: React.ReactNode; - error: string | undefined; -}; - -class EventsPageContent extends React.Component { - state: State = { - incompatibleAlertNotice: null, - error: undefined, - }; - - handleCellAction = (column: TableColumn) => { - return (action: Actions, value: React.ReactText) => { - const {eventView, location} = this.props; - - const searchConditions = new MutableSearch(eventView.query); - - // remove any event.type queries since it is implied to apply to only transactions - searchConditions.removeFilter('event.type'); - - // no need to include transaction as its already in the query params - searchConditions.removeFilter('transaction'); - - updateQuery(searchConditions, action, column, value); - - browserHistory.push({ - pathname: location.pathname, - query: { - ...location.query, - cursor: undefined, - query: searchConditions.formatString(), - }, - }); - }; - }; - - handleIncompatibleQuery: React.ComponentProps< - typeof CreateAlertFromViewButton - >['onIncompatibleQuery'] = (incompatibleAlertNoticeFn, _errors) => { - const incompatibleAlertNotice = incompatibleAlertNoticeFn(() => - this.setState({incompatibleAlertNotice: null}) - ); - this.setState({incompatibleAlertNotice}); - }; +function EventsContent(props: Props) { + const { + location, + organization, + eventView: originalEventView, + transactionName, + spanOperationBreakdownFilter, + webVital, + setError, + } = props; - renderError() { - const {error} = this.state; + const eventView = originalEventView.clone(); - if (!error) { - return null; - } + const transactionsListTitles = [ + t('event id'), + t('user'), + t('operation duration'), + t('total duration'), + t('trace id'), + t('timestamp'), + ]; - return ( - }> - {error} - - ); + if (webVital) { + transactionsListTitles.splice(3, 0, t(webVital)); } - setError = (error: string | undefined) => { - this.setState({error}); - }; + const spanOperationBreakdownConditions = filterToSearchConditions( + spanOperationBreakdownFilter, + location + ); - render() { - const {eventView, location, organization, projects, transactionName, isLoading} = - this.props; - const {incompatibleAlertNotice} = this.state; + if (spanOperationBreakdownConditions) { + eventView.query = `${eventView.query} ${spanOperationBreakdownConditions}`.trim(); + transactionsListTitles.splice(2, 1, t(`${spanOperationBreakdownFilter} duration`)); + } - return ( - - + + + - - - {this.renderError()} - {incompatibleAlertNotice && ( - {incompatibleAlertNotice} - )} - - {isLoading ? ( - - ) : ( - - )} - - - - ); - } -} - -class Body extends React.Component< - Props & {setError: (error: string | undefined) => void}, - State -> { - render() { - let {eventView} = this.props; - const { - location, - organization, - transactionName, - spanOperationBreakdownFilter, - eventsDisplayFilterName, - onChangeEventsDisplayFilter, - setError, - webVital, - } = this.props; - const transactionsListTitles = [ - t('event id'), - t('user'), - t('operation duration'), - t('total duration'), - t('trace id'), - t('timestamp'), - ]; - - if (webVital) { - transactionsListTitles.splice(3, 0, t(webVital)); - } - - const spanOperationBreakdownConditions = filterToSearchConditions( - spanOperationBreakdownFilter, - location - ); - - if (spanOperationBreakdownConditions) { - eventView = eventView.clone(); - eventView.query = `${eventView.query} ${spanOperationBreakdownConditions}`.trim(); - transactionsListTitles.splice(2, 1, t(`${spanOperationBreakdownFilter} duration`)); - } - - return ( - - - - - - - ); - } + + + ); } -const Search = (props: Props) => { +function Search(props: Props) { const { eventView, location, @@ -283,7 +164,7 @@ const Search = (props: Props) => { ); -}; +} const SearchWrapper = styled('div')` display: flex; @@ -291,11 +172,6 @@ const SearchWrapper = styled('div')` margin-bottom: ${space(3)}; `; -const StyledAlert = styled(Alert)` - grid-column: 1/3; - margin: 0; -`; - const StyledSearchBar = styled(SearchBar)` flex-grow: 1; `; @@ -319,4 +195,4 @@ StyledSdkUpdatesAlert.defaultProps = { Wrapper: p => , }; -export default EventsPageContent; +export default EventsContent; diff --git a/static/app/views/performance/transactionSummary/transactionEvents/index.tsx b/static/app/views/performance/transactionSummary/transactionEvents/index.tsx index f3f4b2c0138597..a069d86dcb961b 100644 --- a/static/app/views/performance/transactionSummary/transactionEvents/index.tsx +++ b/static/app/views/performance/transactionSummary/transactionEvents/index.tsx @@ -1,14 +1,20 @@ -import {Component} from 'react'; +import {ReactNode, useState} from 'react'; import {browserHistory} from 'react-router'; +import styled from '@emotion/styled'; import {Location} from 'history'; import Feature from 'app/components/acl/feature'; import Alert from 'app/components/alert'; +import GlobalSdkUpdateAlert from 'app/components/globalSdkUpdateAlert'; +import * as Layout from 'app/components/layouts/thirds'; import LightWeightNoProjectMessage from 'app/components/lightWeightNoProjectMessage'; +import LoadingIndicator from 'app/components/loadingIndicator'; import GlobalSelectionHeader from 'app/components/organizations/globalSelectionHeader'; import SentryDocumentTitle from 'app/components/sentryDocumentTitle'; +import {IconFlag} from 'app/icons'; import {t} from 'app/locale'; -import {GlobalSelection, Organization, Project} from 'app/types'; +import {Organization, Project} from 'app/types'; +import {defined} from 'app/utils'; import {trackAnalyticsEvent} from 'app/utils/analytics'; import DiscoverQuery from 'app/utils/discover/discoverQuery'; import EventView from 'app/utils/discover/eventView'; @@ -22,7 +28,6 @@ import { import {removeHistogramQueryStrings} from 'app/utils/performance/histogram'; import {decodeScalar} from 'app/utils/queryString'; import {MutableSearch} from 'app/utils/tokenizeSearch'; -import withGlobalSelection from 'app/utils/withGlobalSelection'; import withOrganization from 'app/utils/withOrganization'; import withProjects from 'app/utils/withProjects'; @@ -32,57 +37,75 @@ import { filterToLocationQuery, SpanOperationBreakdownFilter, } from '../filter'; +import TransactionHeader from '../header'; +import Tab from '../tabs'; import {ZOOM_END, ZOOM_START} from '../transactionOverview/latencyChart'; -import EventsPageContent from './content'; +import EventsContent from './content'; import { decodeEventsDisplayFilterFromLocation, EventsDisplayFilterName, - EventsFilterPercentileValues, filterEventsDisplayToLocationQuery, getEventsFilterOptions, } from './utils'; +type PercentileValues = Record; + type Props = { location: Location; organization: Organization; projects: Project[]; - selection: GlobalSelection; }; -type State = { - spanOperationBreakdownFilter: SpanOperationBreakdownFilter; - eventsDisplayFilterName: EventsDisplayFilterName; - eventView?: EventView; -}; +function TransactionEvents(props: Props) { + const {location, organization, projects} = props; + const projectId = decodeScalar(location.query.project); + const transactionName = getTransactionName(location); + + if (!defined(projectId) || !defined(transactionName)) { + // If there is no transaction name, redirect to the Performance landing page + browserHistory.replace({ + pathname: `/organizations/${organization.slug}/performance/`, + query: { + ...location.query, + }, + }); + return null; + } -type PercentileValues = Record; -class TransactionEvents extends Component { - state: State = { - spanOperationBreakdownFilter: decodeFilterFromLocation(this.props.location), - eventsDisplayFilterName: decodeEventsDisplayFilterFromLocation(this.props.location), - eventView: generateEventsEventView( - this.props.location, - getTransactionName(this.props.location) - ), + const project = projects.find(p => p.id === projectId); + + const [incompatibleAlertNotice, setIncompatibleAlertNotice] = useState(null); + const handleIncompatibleQuery = (incompatibleAlertNoticeFn, _errors) => { + const notice = incompatibleAlertNoticeFn(() => setIncompatibleAlertNotice(null)); + setIncompatibleAlertNotice(notice); }; - static getDerivedStateFromProps(nextProps: Readonly, prevState: State): State { - return { - ...prevState, - spanOperationBreakdownFilter: decodeFilterFromLocation(nextProps.location), - eventsDisplayFilterName: decodeEventsDisplayFilterFromLocation(nextProps.location), - eventView: generateEventsEventView( - nextProps.location, - getTransactionName(nextProps.location) - ), - }; - } + const [error, setError] = useState(); + + const eventsDisplayFilterName = decodeEventsDisplayFilterFromLocation(location); + const spanOperationBreakdownFilter = decodeFilterFromLocation(location); + const webVital = getWebVital(location); + + const eventView = generateEventView(location, transactionName); + const percentilesView = getPercentilesEventView(eventView); - onChangeSpanOperationBreakdownFilter = (newFilter: SpanOperationBreakdownFilter) => { - const {location, organization} = this.props; - const {spanOperationBreakdownFilter, eventsDisplayFilterName, eventView} = this.state; + const getFilteredEventView = (percentiles: PercentileValues) => { + const filter = getEventsFilterOptions(spanOperationBreakdownFilter, percentiles)[ + eventsDisplayFilterName + ]; + const filteredEventView = eventView?.clone(); + if (filteredEventView && filter?.query) { + const query = new MutableSearch(filteredEventView.query); + filter.query.forEach(item => query.setFilterValues(item[0], [item[1]])); + filteredEventView.query = query.formatString(); + } + return filteredEventView; + }; + const onChangeSpanOperationBreakdownFilter = ( + newFilter: SpanOperationBreakdownFilter + ) => { trackAnalyticsEvent({ eventName: 'Performance Views: Transaction Events Ops Breakdown Filter Dropdown', eventKey: 'performance_views.transactionEvents.ops_filter_dropdown.selection', @@ -120,21 +143,14 @@ class TransactionEvents extends Component { }); }; - onChangeEventsDisplayFilter = (newFilterName: EventsDisplayFilterName) => { - const {organization} = this.props; - + const onChangeEventsDisplayFilter = (newFilterName: EventsDisplayFilterName) => { trackAnalyticsEvent({ eventName: 'Performance Views: Transaction Events Display Filter Dropdown', eventKey: 'performance_views.transactionEvents.display_filter_dropdown.selection', organization_id: parseInt(organization.id, 10), action: newFilterName as string, }); - this.filterDropdownSortEvents(newFilterName); - }; - filterDropdownSortEvents = (newFilterName: EventsDisplayFilterName) => { - const {location} = this.props; - const {spanOperationBreakdownFilter} = this.state; const nextQuery: Location['query'] = { ...removeHistogramQueryStrings(location, [ZOOM_START, ZOOM_END]), ...filterEventsDisplayToLocationQuery(newFilterName, spanOperationBreakdownFilter), @@ -150,109 +166,46 @@ class TransactionEvents extends Component { }); }; - getFilteredEventView = (percentiles: EventsFilterPercentileValues) => { - const {eventsDisplayFilterName, spanOperationBreakdownFilter, eventView} = this.state; - const filter = getEventsFilterOptions(spanOperationBreakdownFilter, percentiles)[ - eventsDisplayFilterName - ]; - const filteredEventView = eventView?.clone(); - if (filteredEventView && filter?.query) { - const query = new MutableSearch(filteredEventView.query); - filter.query.forEach(item => query.setFilterValues(item[0], [item[1]])); - filteredEventView.query = query.formatString(); - } - return filteredEventView; - }; - - getDocumentTitle(): string { - const name = getTransactionName(this.props.location); - - const hasTransactionName = typeof name === 'string' && String(name).trim().length > 0; - - if (hasTransactionName) { - return [String(name).trim(), t('Events')].join(' \u2014 '); - } - - return [t('Summary'), t('Events')].join(' \u2014 '); - } - - getPercentilesEventView(eventView: EventView): EventView { - const percentileColumns: QueryFieldValue[] = [ - { - kind: 'function', - function: ['p100', '', undefined, undefined], - }, - { - kind: 'function', - function: ['p99', '', undefined, undefined], - }, - { - kind: 'function', - function: ['p95', '', undefined, undefined], - }, - { - kind: 'function', - function: ['p75', '', undefined, undefined], - }, - { - kind: 'function', - function: ['p50', '', undefined, undefined], - }, - ]; - - return eventView.withColumns([...percentileColumns]); - } - - renderNoAccess = () => { - return {t("You don't have access to this feature")}; - }; - - render() { - const {organization, projects, location} = this.props; - const {eventView} = this.state; - const transactionName = getTransactionName(location); - const webVital = getWebVital(location); - if (!eventView || transactionName === undefined) { - // If there is no transaction name, redirect to the Performance landing page - browserHistory.replace({ - pathname: `/organizations/${organization.slug}/performance/`, - query: { - ...location.query, - }, - }); - return null; - } - const percentilesView = this.getPercentilesEventView(eventView); - - const shouldForceProject = eventView.project.length === 1; - const forceProject = shouldForceProject - ? projects.find(p => parseInt(p.id, 10) === eventView.project[0]) - : undefined; - const projectSlugs = eventView.project - .map(projectId => projects.find(p => parseInt(p.id, 10) === projectId)) - .filter((p: Project | undefined): p is Project => p !== undefined) - .map(p => p.slug); - - return ( - + - - - + + + + + {defined(error) && ( + }> + {error} + + )} + {incompatibleAlertNotice && ( + {incompatibleAlertNotice} + )} { referrer="api.performance.transaction-events" > {({isLoading, tableData}) => { + if (isLoading) { + return ( + + + + ); + } + const percentiles: PercentileValues = tableData?.data?.[0]; + const filteredEventView = getFilteredEventView(percentiles); + return ( - ); }} - - - - - ); + + + + + + ); +} + +function getDocumentTitle(transactionName: string): string { + const hasTransactionName = + typeof transactionName === 'string' && String(transactionName).trim().length > 0; + + if (hasTransactionName) { + return [String(transactionName).trim(), t('Events')].join(' \u2014 '); } + + return [t('Summary'), t('Events')].join(' \u2014 '); +} + +function NoAccess() { + return {t("You don't have access to this feature")}; } function getWebVital(location: Location): WebVital | undefined { @@ -299,13 +274,7 @@ function getWebVital(location: Location): WebVital | undefined { return undefined; } -function generateEventsEventView( - location: Location, - transactionName?: string -): EventView | undefined { - if (transactionName === undefined) { - return undefined; - } +function generateEventView(location: Location, transactionName: string): EventView { const query = decodeScalar(location.query.query, ''); const conditions = new MutableSearch(query); conditions @@ -350,4 +319,46 @@ function generateEventsEventView( ); } -export default withGlobalSelection(withProjects(withOrganization(TransactionEvents))); +function getPercentilesEventView(eventView: EventView): EventView { + const percentileColumns: QueryFieldValue[] = [ + { + kind: 'function', + function: ['p100', '', undefined, undefined], + }, + { + kind: 'function', + function: ['p99', '', undefined, undefined], + }, + { + kind: 'function', + function: ['p95', '', undefined, undefined], + }, + { + kind: 'function', + function: ['p75', '', undefined, undefined], + }, + { + kind: 'function', + function: ['p50', '', undefined, undefined], + }, + ]; + + return eventView.withColumns(percentileColumns); +} + +const StyledAlert = styled(Alert)` + grid-column: 1/3; + margin: 0; +`; + +const StyledSdkUpdatesAlert = styled(GlobalSdkUpdateAlert)` + @media (min-width: ${p => p.theme.breakpoints[1]}) { + margin-bottom: 0; + } +`; + +StyledSdkUpdatesAlert.defaultProps = { + Wrapper: p => , +}; + +export default withProjects(withOrganization(TransactionEvents)); diff --git a/static/app/views/performance/transactionSummary/types.tsx b/static/app/views/performance/transactionSummary/types.tsx new file mode 100644 index 00000000000000..66ad8297023b61 --- /dev/null +++ b/static/app/views/performance/transactionSummary/types.tsx @@ -0,0 +1,3 @@ +import {Dispatch, SetStateAction as _SetStateAction} from 'react'; + +export type SetStateAction = Dispatch<_SetStateAction>; diff --git a/tests/js/spec/views/performance/transactionEvents.spec.tsx b/tests/js/spec/views/performance/transactionEvents.spec.tsx index 9e5f9f3f59de43..6e1329105df676 100644 --- a/tests/js/spec/views/performance/transactionEvents.spec.tsx +++ b/tests/js/spec/views/performance/transactionEvents.spec.tsx @@ -28,7 +28,7 @@ function initializeData({features: additionalFeatures = [], query = {}}: Data = location: { query: { transaction: '/performance', - project: 1, + project: '1', transactionCursor: '1:0:0', ...query, }, @@ -160,7 +160,6 @@ describe('Performance > TransactionSummary', function () { , initialData.routerContext ); @@ -175,7 +174,8 @@ describe('Performance > TransactionSummary', function () { expect(wrapper.find('SearchBar')).toHaveLength(1); expect(wrapper.find('GridEditable')).toHaveLength(1); expect(wrapper.find('Pagination')).toHaveLength(1); - expect(wrapper.find('EventsPageContent')).toHaveLength(1); + expect(wrapper.find('EventsContent')).toHaveLength(1); + expect(wrapper.find('TransactionHeader')).toHaveLength(1); }); it('renders alert when not feature flagged', async function () { @@ -184,7 +184,6 @@ describe('Performance > TransactionSummary', function () { , initialData.routerContext ); @@ -197,7 +196,8 @@ describe('Performance > TransactionSummary', function () { expect(wrapper.find('SearchBar')).toHaveLength(0); expect(wrapper.find('TransactionsTable')).toHaveLength(0); expect(wrapper.find('Pagination')).toHaveLength(0); - expect(wrapper.find('EventsPageContent')).toHaveLength(0); + expect(wrapper.find('EventsContent')).toHaveLength(0); + expect(wrapper.find('TransactionHeader')).toHaveLength(0); }); it('renders relative span breakdown header when no filter selected', async function () { @@ -206,7 +206,6 @@ describe('Performance > TransactionSummary', function () { , initialData.routerContext ); @@ -226,7 +225,6 @@ describe('Performance > TransactionSummary', function () { , initialData.routerContext ); @@ -266,7 +264,6 @@ describe('Performance > TransactionSummary', function () { , initialData.routerContext ); diff --git a/tests/js/spec/views/performance/transactionSummary/transactionEvents/content.spec.tsx b/tests/js/spec/views/performance/transactionSummary/transactionEvents/content.spec.tsx index 3bc970f14925fd..3f84c1ad836756 100644 --- a/tests/js/spec/views/performance/transactionSummary/transactionEvents/content.spec.tsx +++ b/tests/js/spec/views/performance/transactionSummary/transactionEvents/content.spec.tsx @@ -186,12 +186,11 @@ describe('Performance Transaction Events Content', function () { organization={organization} location={initialData.router.location} transactionName={transactionName} - projects={initialData.projects} spanOperationBreakdownFilter={SpanOperationBreakdownFilter.None} onChangeSpanOperationBreakdownFilter={() => {}} eventsDisplayFilterName={EventsDisplayFilterName.p100} onChangeEventsDisplayFilter={() => {}} - isLoading={false} + setError={() => {}} />, initialData.routerContext ); @@ -203,7 +202,6 @@ describe('Performance Transaction Events Content', function () { expect(wrapper.find('SearchRowMenuItem')).toHaveLength(2); expect(wrapper.find('StyledSearchBar')).toHaveLength(1); expect(wrapper.find('Filter')).toHaveLength(1); - expect(wrapper.find('TransactionHeader')).toHaveLength(1); const columnTitles = wrapper.find('EventsTable').props().columnTitles; expect(columnTitles).toEqual([ @@ -223,13 +221,12 @@ describe('Performance Transaction Events Content', function () { organization={organization} location={initialData.router.location} transactionName={transactionName} - projects={initialData.projects} spanOperationBreakdownFilter={SpanOperationBreakdownFilter.None} onChangeSpanOperationBreakdownFilter={() => {}} eventsDisplayFilterName={EventsDisplayFilterName.p100} onChangeEventsDisplayFilter={() => {}} - isLoading={false} webVital={WebVital.LCP} + setError={() => {}} />, initialData.routerContext ); @@ -241,7 +238,6 @@ describe('Performance Transaction Events Content', function () { expect(wrapper.find('SearchRowMenuItem')).toHaveLength(2); expect(wrapper.find('StyledSearchBar')).toHaveLength(1); expect(wrapper.find('Filter')).toHaveLength(1); - expect(wrapper.find('TransactionHeader')).toHaveLength(1); const columnTitles = wrapper.find('EventsTable').props().columnTitles; expect(columnTitles).toEqual([