diff --git a/src/sentry/static/sentry/app/actionCreators/events.jsx b/src/sentry/static/sentry/app/actionCreators/events.jsx index 643080c02b45..f788a43d7371 100644 --- a/src/sentry/static/sentry/app/actionCreators/events.jsx +++ b/src/sentry/static/sentry/app/actionCreators/events.jsx @@ -31,6 +31,7 @@ export const doEventsRequest = ( limit, query, yAxis, + groupId, } ) => { const shouldDoublePeriod = canIncludePreviousPeriod(includePrevious, period); @@ -40,6 +41,7 @@ export const doEventsRequest = ( environment, query, yAxis, + group: groupId, }; // Doubling period for absolute dates is not accurate unless starting and diff --git a/src/sentry/static/sentry/app/icons/icon-next.svg b/src/sentry/static/sentry/app/icons/icon-next.svg new file mode 100644 index 000000000000..878c589ca201 --- /dev/null +++ b/src/sentry/static/sentry/app/icons/icon-next.svg @@ -0,0 +1 @@ + diff --git a/src/sentry/static/sentry/app/icons/icon-prev.svg b/src/sentry/static/sentry/app/icons/icon-prev.svg new file mode 100644 index 000000000000..5fe53a4bd2c3 --- /dev/null +++ b/src/sentry/static/sentry/app/icons/icon-prev.svg @@ -0,0 +1 @@ + diff --git a/src/sentry/static/sentry/app/views/organizationEvents/utils/eventsRequest.jsx b/src/sentry/static/sentry/app/views/organizationEvents/utils/eventsRequest.jsx index 0e5af7be9d57..7bade2ffa895 100644 --- a/src/sentry/static/sentry/app/views/organizationEvents/utils/eventsRequest.jsx +++ b/src/sentry/static/sentry/app/views/organizationEvents/utils/eventsRequest.jsx @@ -98,12 +98,18 @@ class EventsRequest extends React.PureComponent { * The yAxis being plotted */ yAxis: PropTypes.string, + + /** + * issue group id or groupids to filter results by. + */ + groupId: PropTypes.oneOfType([PropTypes.string, PropTypes.array]), }; static defaultProps = { period: null, start: null, end: null, + groupId: null, interval: '1d', limit: 15, getCategory: i => i, diff --git a/src/sentry/static/sentry/app/views/organizationEventsV2/data.jsx b/src/sentry/static/sentry/app/views/organizationEventsV2/data.jsx index 543c809d70db..32857d61aac0 100644 --- a/src/sentry/static/sentry/app/views/organizationEventsV2/data.jsx +++ b/src/sentry/static/sentry/app/views/organizationEventsV2/data.jsx @@ -131,12 +131,42 @@ export const SPECIAL_FIELDS = { ), }, error: { - fields: ['issue_title'], - renderFunc: data => {data.issue_title}, + fields: ['issue_title', 'project.name', 'issue.id'], + renderFunc: (data, {organization, location}) => { + const target = { + pathname: `/organizations/${organization.slug}/events/`, + query: { + ...location.query, + groupSlug: `${data['project.name']}:${data['issue.id']}:latest`, + }, + }; + return ( + + + {data.issue_title} + + + ); + }, }, csp: { - fields: ['issue_title'], - renderFunc: data => {data.issue_title}, + fields: ['issue_title', 'project.name', 'issue.id'], + renderFunc: (data, {organization, location}) => { + const target = { + pathname: `/organizations/${organization.slug}/events/`, + query: { + ...location.query, + groupSlug: `${data['project.name']}:${data['issue.id']}:latest`, + }, + }; + return ( + + + {data.issue_title} + + + ); + }, }, event_count: { title: 'events', diff --git a/src/sentry/static/sentry/app/views/organizationEventsV2/eventDetails.jsx b/src/sentry/static/sentry/app/views/organizationEventsV2/eventDetails.jsx index 3a65a03733ab..2b5fcbde4c2a 100644 --- a/src/sentry/static/sentry/app/views/organizationEventsV2/eventDetails.jsx +++ b/src/sentry/static/sentry/app/views/organizationEventsV2/eventDetails.jsx @@ -2,25 +2,75 @@ import React from 'react'; import styled from 'react-emotion'; import {browserHistory} from 'react-router'; import PropTypes from 'prop-types'; +import {omit} from 'lodash'; +import SentryTypes from 'app/sentryTypes'; import AsyncComponent from 'app/components/asyncComponent'; import InlineSvg from 'app/components/inlineSvg'; import withApi from 'app/utils/withApi'; import space from 'app/styles/space'; import EventModalContent from './eventModalContent'; +import {getQuery} from './utils'; + +const slugValidator = function(props, propName, componentName) { + const value = props[propName]; + // Accept slugs that look like: + // * project-slug:123:latest + // * project-slug:123:oldest + // * project-slug:123:deadbeef + // * project-slug:deadbeef + if (value && !/^(?:[^:]+:)?(?:[^:]+):(?:[a-f0-9]+|latest|oldest)$/.test(value)) { + return new Error(`Invalid value for ${propName} provided to ${componentName}.`); + } + return null; +}; class EventDetails extends AsyncComponent { static propTypes = { - params: PropTypes.object, - eventSlug: PropTypes.string.isRequired, + organization: SentryTypes.Organization.isRequired, + eventSlug: slugValidator, + groupSlug: slugValidator, + location: PropTypes.object.isRequired, + view: PropTypes.object.isRequired, }; getEndpoints() { - const {orgId} = this.props.params; - const [projectId, eventId] = this.props.eventSlug.toString().split(':'); - - return [['event', `/projects/${orgId}/${projectId}/events/${eventId}/`]]; + const {organization, eventSlug, groupSlug, view, location} = this.props; + const query = getQuery(view, location); + + // If we're getting an issue/group use the latest endpoint. + // We pass the current query/view state to the API so we get an + // event that matches the current list filters. + if (groupSlug) { + const [projectId, groupId, eventId] = groupSlug.toString().split(':'); + + let url = `/organizations/${organization.slug}/events/`; + // latest / oldest have dedicated endpoints + if (['latest', 'oldest'].includes(eventId)) { + url += 'latest/'; + } else { + url += `${projectId}:${eventId}/`; + } + if (query.query) { + query.query += ` issue.id:${groupId}`; + } else { + query.query = `issue.id:${groupId}`; + } + + return [['event', url, {query}]]; + } + + // Get a specific event. This could be coming from + // a paginated group or standalone event. + const [projectId, eventId] = eventSlug.toString().split(':'); + return [ + [ + 'event', + `/organizations/${organization.slug}/events/${projectId}:${eventId}/`, + {query}, + ], + ]; } onRequestSuccess({data}) { @@ -30,23 +80,45 @@ class EventDetails extends AsyncComponent { handleClose = event => { event.preventDefault(); - browserHistory.goBack(); + const {location} = this.props; + // Remove modal related query parameters. + const query = omit(location.query, ['groupSlug', 'eventSlug']); + + browserHistory.push({ + pathname: location.pathname, + query, + }); }; handleTabChange = tab => this.setState({activeTab: tab}); + get projectId() { + if (this.props.eventSlug) { + const [projectId] = this.props.eventSlug.split(':'); + return projectId; + } + if (this.props.groupSlug) { + const [projectId] = this.props.groupSlug.split(':'); + return projectId; + } + throw new Error('Could not determine projectId'); + } + renderBody() { - const {orgId} = this.props.params; - const [projectId, _] = this.props.eventSlug.split(':'); + const {organization, view, location} = this.props; + const {event, activeTab} = this.state; + return ( ); diff --git a/src/sentry/static/sentry/app/views/organizationEventsV2/eventModalContent.jsx b/src/sentry/static/sentry/app/views/organizationEventsV2/eventModalContent.jsx index c08cac11d039..cb469cf58321 100644 --- a/src/sentry/static/sentry/app/views/organizationEventsV2/eventModalContent.jsx +++ b/src/sentry/static/sentry/app/views/organizationEventsV2/eventModalContent.jsx @@ -20,6 +20,8 @@ import getDynamicText from 'app/utils/getDynamicText'; import space from 'app/styles/space'; import LinkedIssuePreview from './linkedIssuePreview'; +import ModalPagination from './modalPagination'; +import ModalLineGraph from './modalLineGraph'; import TagsTable from './tagsTable'; const OTHER_SECTIONS = { @@ -76,15 +78,30 @@ ActiveTab.propTypes = { * Controlled by the EventDetails View. */ const EventModalContent = props => { - const {event, activeTab, projectId, orgId, onTabChange} = props; - const eventJsonUrl = `/api/0/projects/${orgId}/${projectId}/events/${ + const {event, activeTab, projectId, organization, onTabChange, location, view} = props; + const isGroupedView = !!view.data.groupby; + const eventJsonUrl = `/api/0/projects/${organization.slug}/${projectId}/events/${ event.eventID }/json/`; return ( - + + {isGroupedView && } + {isGroupedView && + getDynamicText({ + value: ( + + ), + fixed: 'events chart', + })} + + {event.entries.map(entry => { if (!INTERFACES.hasOwnProperty(entry.type)) { @@ -143,7 +160,9 @@ const EventModalContent = props => { EventModalContent.propTypes = { ...ActiveTab.propTypes, onTabChange: PropTypes.func.isRequired, - orgId: PropTypes.string.isRequired, + organization: SentryTypes.Organization.isRequired, + view: PropTypes.object.isRequired, + location: PropTypes.object.isRequired, }; /** @@ -197,11 +216,14 @@ const MetadataContainer = styled('div')` const ColumnGrid = styled('div')` display: grid; - grid-template-columns: 70% 1fr; + grid-template-columns: 70% 28%; grid-template-rows: auto; - grid-column-gap: ${space(3)}; + grid-column-gap: 2%; `; +const HeaderBox = styled('div')` + grid-column: 1 / 3; +`; const ContentColumn = styled('div')` grid-column: 1 / 2; `; diff --git a/src/sentry/static/sentry/app/views/organizationEventsV2/index.jsx b/src/sentry/static/sentry/app/views/organizationEventsV2/index.jsx index f191a8d44db6..2ee973adf791 100644 --- a/src/sentry/static/sentry/app/views/organizationEventsV2/index.jsx +++ b/src/sentry/static/sentry/app/views/organizationEventsV2/index.jsx @@ -48,7 +48,9 @@ export default class OrganizationEventsV2 extends React.Component { render() { const {organization, location, router} = this.props; - const {eventSlug} = location.query; + const {eventSlug, groupSlug} = location.query; + const currentView = getCurrentView(location.query.view); + const showModal = groupSlug || eventSlug; return ( @@ -64,16 +66,19 @@ export default class OrganizationEventsV2 extends React.Component { {this.renderTabs()} - {eventSlug && ( + {showModal && ( )} diff --git a/src/sentry/static/sentry/app/views/organizationEventsV2/modalLineGraph.jsx b/src/sentry/static/sentry/app/views/organizationEventsV2/modalLineGraph.jsx new file mode 100644 index 000000000000..401b6b6b0848 --- /dev/null +++ b/src/sentry/static/sentry/app/views/organizationEventsV2/modalLineGraph.jsx @@ -0,0 +1,86 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import {t} from 'app/locale'; +import SentryTypes from 'app/sentryTypes'; +import {getInterval, useShortInterval} from 'app/components/charts/utils'; +import {getFormattedDate} from 'app/utils/dates'; +import EventsRequest from 'app/views/organizationEvents/utils/eventsRequest'; +import LineChart from 'app/components/charts/lineChart'; +import {Panel} from 'app/components/panels'; +import withApi from 'app/utils/withApi'; +import withGlobalSelection from 'app/utils/withGlobalSelection'; + +const defaultGetCategory = () => t('Events'); + +const ModalLineGraph = props => { + const {api, groupId, organization, location, selection} = props; + + const isUtc = selection.datetime.utc; + const dateFormat = 'lll'; + + const interval = getInterval(selection.datetime, true); + const hasShortInterval = useShortInterval(selection.datetime); + + const xAxisOptions = { + axisLabel: { + formatter: (value, index) => { + const firstItem = index === 0; + const format = hasShortInterval && !firstItem ? 'LT' : dateFormat; + return getFormattedDate(value, format, {local: !isUtc}); + }, + }, + }; + + const tooltip = { + formatAxisLabel: value => { + return getFormattedDate(value, 'lll', {local: !isUtc}); + }, + }; + + return ( + + + {({loading, reloading, timeseriesData}) => ( + + )} + + + ); +}; +ModalLineGraph.propTypes = { + api: PropTypes.object.isRequired, + organization: SentryTypes.Organization.isRequired, + groupId: PropTypes.string.isRequired, + location: PropTypes.object.isRequired, + selection: PropTypes.object.isRequired, +}; + +export default withGlobalSelection(withApi(ModalLineGraph)); diff --git a/src/sentry/static/sentry/app/views/organizationEventsV2/modalPagination.jsx b/src/sentry/static/sentry/app/views/organizationEventsV2/modalPagination.jsx new file mode 100644 index 000000000000..77971b120856 --- /dev/null +++ b/src/sentry/static/sentry/app/views/organizationEventsV2/modalPagination.jsx @@ -0,0 +1,97 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import styled from 'react-emotion'; +import isPropValid from '@emotion/is-prop-valid'; +import {omit} from 'lodash'; + +import {t} from 'app/locale'; +import Link from 'app/components/links/link'; +import SentryTypes from 'app/sentryTypes'; +import InlineSvg from 'app/components/inlineSvg'; +import space from 'app/styles/space'; + +const ModalPagination = props => { + const {location, event} = props; + + // Remove the groupSlug and eventSlug keys as we need to create new ones + const query = omit(location.query, ['groupSlug', 'eventSlug']); + const previousEventUrl = event.previousEventID + ? { + pathname: location.pathname, + query: { + ...query, + groupSlug: `${event.projectSlug}:${event.groupID}:${event.previousEventID}`, + }, + } + : null; + const nextEventUrl = event.nextEventID + ? { + pathname: location.pathname, + query: { + ...query, + groupSlug: `${event.projectSlug}:${event.groupID}:${event.nextEventID}`, + }, + } + : null; + const newestUrl = { + pathname: location.pathname, + query: { + ...query, + groupSlug: `${event.projectSlug}:${event.groupID}:latest`, + }, + }; + const oldestUrl = { + pathname: location.pathname, + query: { + ...query, + groupSlug: `${event.projectSlug}:${event.groupID}:oldest`, + }, + }; + + return ( + + + + + + + {t('Older Event')} + + + {t('Newer Event')} + + + + + + + ); +}; +ModalPagination.propTypes = { + location: PropTypes.object.isRequired, + event: SentryTypes.Event.isRequired, +}; + +const StyledLink = styled(Link, {shouldForwardProp: isPropValid})` + color: ${p => (p.disabled ? p.theme.disabled : p.theme.gray3)}; + font-size: ${p => p.fontSizeMedium}; + padding: ${space(0.5)} ${space(1.5)}; + ${p => (p.isLast ? '' : `border-right: 1px solid ${p.theme.borderDark};`)} + ${p => (p.disabled ? 'pointer-events: none;' : '')} +`; + +const Wrapper = styled('div')` + display: flex; +`; + +const ShadowBox = styled('div')` + display: flex; + background: ${p => p.theme.offWhite}; + border: 1px solid ${p => p.theme.borderDark}; + border-radius: ${p => p.theme.borderRadius}; + margin-bottom: ${space(3)}; + box-shadow: 3px 3px 0 ${p => p.theme.offWhite}, 3px 3px 0 1px ${p => p.theme.borderDark}, + 7px 7px ${p => p.theme.offWhite}, 7px 7px 0 1px ${p => p.theme.borderDark}; +`; + +export default ModalPagination; diff --git a/tests/js/setup.js b/tests/js/setup.js index 3ea003af3407..82606d48b5d4 100644 --- a/tests/js/setup.js +++ b/tests/js/setup.js @@ -65,6 +65,7 @@ jest.mock('react-router', () => { Route: ReactRouter.Route, withRouter: ReactRouter.withRouter, browserHistory: { + goBack: jest.fn(), push: jest.fn(), replace: jest.fn(), listen: jest.fn(() => {}), diff --git a/tests/js/spec/views/organizationEventsV2/eventDetails.spec.jsx b/tests/js/spec/views/organizationEventsV2/eventDetails.spec.jsx new file mode 100644 index 000000000000..a87b6db1d810 --- /dev/null +++ b/tests/js/spec/views/organizationEventsV2/eventDetails.spec.jsx @@ -0,0 +1,244 @@ +import React from 'react'; +import {mount} from 'enzyme'; +import {initializeOrg} from 'app-test/helpers/initializeOrg'; +import {browserHistory} from 'react-router'; + +import EventDetails from 'app/views/organizationEventsV2/eventDetails'; +import {ALL_VIEWS} from 'app/views/organizationEventsV2/data'; + +describe('OrganizationEventsV2 > EventDetails', function() { + const allEventsView = ALL_VIEWS.find(view => view.id === 'all'); + const errorsView = ALL_VIEWS.find(view => view.id === 'errors'); + + beforeEach(function() { + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/events/', + body: [ + { + id: 'deadbeef', + title: 'Oh no something bad', + 'project.name': 'project-slug', + timestamp: '2019-05-23T22:12:48+00:00', + }, + ], + }); + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/events/project-slug:deadbeef/', + method: 'GET', + body: { + id: '1234', + size: 1200, + projectSlug: 'org-slug', + eventID: 'deadbeef', + groupID: '123', + title: 'Oh no something bad', + message: 'It was not good', + dateCreated: '2019-05-23T22:12:48+00:00', + entries: [ + { + type: 'message', + message: 'bad stuff', + data: {}, + }, + ], + tags: [{key: 'browser', value: 'Firefox'}], + }, + }); + MockApiClient.addMockResponse({ + url: '/issues/123/', + method: 'GET', + body: TestStubs.Group({id: '123'}), + }); + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/events-stats/', + method: 'GET', + body: { + data: [[1234561700, [1]], [1234561800, [1]]], + }, + }); + + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/events/latest/', + method: 'GET', + body: { + id: '1234', + size: 1200, + projectSlug: 'org-slug', + eventID: 'deadbeef', + groupID: '123', + title: 'Oh no something bad', + message: 'It was not good', + dateCreated: '2019-05-23T22:12:48+00:00', + entries: [ + { + type: 'message', + message: 'bad stuff', + data: {}, + }, + ], + tags: [{key: 'browser', value: 'Firefox'}], + }, + }); + }); + + it('renders', function() { + const wrapper = mount( + , + TestStubs.routerContext() + ); + const content = wrapper.find('EventHeader'); + expect(content.text()).toContain('Oh no something bad'); + + const graph = wrapper.find('ModalLineGraph'); + expect(graph).toHaveLength(0); + }); + + it('renders a chart in grouped view', function() { + const wrapper = mount( + , + TestStubs.routerContext() + ); + const content = wrapper.find('EventHeader'); + expect(content.text()).toContain('Oh no something bad'); + + const graph = wrapper.find('ModalLineGraph'); + expect(graph).toHaveLength(1); + }); + + it('renders pagination buttons in grouped view', function() { + const wrapper = mount( + , + TestStubs.routerContext() + ); + const content = wrapper.find('ModalPagination'); + expect(content).toHaveLength(1); + }); + + it('removes eventSlug when close button is clicked', function() { + const wrapper = mount( + , + TestStubs.routerContext() + ); + const button = wrapper.find('CloseButton'); + button.simulate('click'); + expect(browserHistory.push).toHaveBeenCalledWith({ + pathname: '/organizations/org-slug/events/', + query: {}, + }); + }); + + it('removes groupSlug when close button is clicked', function() { + const wrapper = mount( + , + TestStubs.routerContext() + ); + const button = wrapper.find('CloseButton'); + button.simulate('click'); + expect(browserHistory.push).toHaveBeenCalledWith({ + pathname: '/organizations/org-slug/events/', + query: {}, + }); + }); + + it('navigates when tag values are clicked', async function() { + const {organization, routerContext} = initializeOrg({ + organization: TestStubs.Organization({projects: [TestStubs.Project()]}), + router: { + location: { + pathname: '/organizations/org-slug/events/', + query: { + eventSlug: 'project-slug:deadbeef', + }, + }, + }, + }); + const wrapper = mount( + , + routerContext + ); + await tick(); + await wrapper.update(); + + // Get the first link as we wrap react-router's link + const tagLink = wrapper.find('EventDetails TagsTable TagValue Link').first(); + + // Should remove eventSlug and append new tag value causing + // the view to re-render + expect(tagLink.props().to).toEqual({ + pathname: '/organizations/org-slug/events/', + query: {query: 'browser:"Firefox"'}, + }); + }); + + it('appends tag value to existing query when clicked', async function() { + const {organization, routerContext} = initializeOrg({ + organization: TestStubs.Organization({projects: [TestStubs.Project()]}), + router: { + location: { + pathname: '/organizations/org-slug/events/', + query: { + query: 'Dumpster', + eventSlug: 'project-slug:deadbeef', + }, + }, + }, + }); + const wrapper = mount( + , + routerContext + ); + await tick(); + await wrapper.update(); + + // Get the first link as we wrap react-router's link + const tagLink = wrapper.find('EventDetails TagsTable TagValue Link').first(); + + // Should remove eventSlug and append new tag value causing + // the view to re-render + expect(tagLink.props().to).toEqual({ + pathname: '/organizations/org-slug/events/', + query: {query: 'Dumpster browser:"Firefox"'}, + }); + }); +}); diff --git a/tests/js/spec/views/organizationEventsV2/index.spec.jsx b/tests/js/spec/views/organizationEventsV2/index.spec.jsx index f1680b672e7e..6c0cdc390d71 100644 --- a/tests/js/spec/views/organizationEventsV2/index.spec.jsx +++ b/tests/js/spec/views/organizationEventsV2/index.spec.jsx @@ -1,6 +1,5 @@ import React from 'react'; import {mount} from 'enzyme'; -import {initializeOrg} from 'app-test/helpers/initializeOrg'; import OrganizationEventsV2 from 'app/views/organizationEventsV2'; @@ -18,7 +17,7 @@ describe('OrganizationEventsV2', function() { ], }); MockApiClient.addMockResponse({ - url: '/projects/org-slug/project-slug/events/deadbeef/', + url: '/organizations/org-slug/events/project-slug:deadbeef/', method: 'GET', body: { id: '1234', @@ -96,74 +95,19 @@ describe('OrganizationEventsV2', function() { expect(modal).toHaveLength(1); }); - it('navigates when tag values are clicked', async function() { - const {organization, routerContext} = initializeOrg({ - organization: TestStubs.Organization({projects: [TestStubs.Project()]}), - router: { - location: { - pathname: '/organizations/org-slug/events/', - query: { - eventSlug: 'project-slug:deadbeef', - }, - }, - }, - }); - const wrapper = mount( - , - routerContext - ); - await tick(); - await wrapper.update(); - - // Get the first link as we wrap react-router's link - const tagLink = wrapper.find('EventDetails TagsTable TagValue Link').first(); - - // Should remove eventSlug and append new tag value causing - // the view to re-render - expect(tagLink.props().to).toEqual({ - pathname: '/organizations/org-slug/events/', - query: {query: 'browser:"Firefox"'}, - }); - }); - - it('appends tag value to existing query when clicked', async function() { - const {organization, routerContext} = initializeOrg({ - organization: TestStubs.Organization({projects: [TestStubs.Project()]}), - router: { - location: { - pathname: '/organizations/org-slug/events/', - query: { - query: 'Dumpster', - eventSlug: 'project-slug:deadbeef', - }, - }, - }, - }); + it('opens a modal when groupSlug is present', async function() { + const organization = TestStubs.Organization({projects: [TestStubs.Project()]}); const wrapper = mount( , - routerContext + TestStubs.routerContext() ); - await tick(); - await wrapper.update(); - - // Get the first link as we wrap react-router's link - const tagLink = wrapper.find('EventDetails TagsTable TagValue Link').first(); - // Should remove eventSlug and append new tag value causing - // the view to re-render - expect(tagLink.props().to).toEqual({ - pathname: '/organizations/org-slug/events/', - query: {query: 'Dumpster browser:"Firefox"'}, - }); + const modal = wrapper.find('EventDetails'); + expect(modal).toHaveLength(1); }); });